mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-14 00:11:01 +03:00
Compare commits
71 Commits
master
...
fix-unzoom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c02f17212a | ||
|
|
fbfa37c8fd | ||
|
|
819535f1db | ||
|
|
110ddb9a6c | ||
|
|
9a1c1a1a36 | ||
|
|
3629db2aa0 | ||
|
|
8869a440da | ||
|
|
777407e72e | ||
|
|
20ede1a85f | ||
|
|
a87d9c1082 | ||
|
|
bb06f54070 | ||
|
|
16088cfc80 | ||
|
|
b630f14483 | ||
|
|
d2d7c83602 | ||
|
|
d2561f7f6b | ||
|
|
70c50402a2 | ||
|
|
bbc8ee3bdb | ||
|
|
32e9895294 | ||
|
|
feff308f11 | ||
|
|
5a89aa8c58 | ||
|
|
6e2d838bf1 | ||
|
|
1fabdd6a9c | ||
|
|
afdd1d5c9d | ||
|
|
371e5e8f2d | ||
|
|
4d064a8445 | ||
|
|
02e4f02815 | ||
|
|
bbf86259e9 | ||
|
|
8dfff101f8 | ||
|
|
6934b3cb14 | ||
|
|
8f3beba2d1 | ||
|
|
be1f4e0a33 | ||
|
|
cb068ba5df | ||
|
|
1d98d1a647 | ||
|
|
944f93ba7b | ||
|
|
aea8e2a4a6 | ||
|
|
d65faca21f | ||
|
|
85e7c72ccd | ||
|
|
a3bfe075d5 | ||
|
|
7e2e9b68da | ||
|
|
6871ae130e | ||
|
|
54881092b7 | ||
|
|
34ceb16b63 | ||
|
|
d95c3f2ec9 | ||
|
|
daafa09318 | ||
|
|
96cd5cd1e2 | ||
|
|
d876ea0ce4 | ||
|
|
2a61a0955a | ||
|
|
db7c48e1a7 | ||
|
|
e72e7fb8ba | ||
|
|
4f0f784532 | ||
|
|
844bf40703 | ||
|
|
f8bfc4eb56 | ||
|
|
1061670368 | ||
|
|
8da629c57d | ||
|
|
650a6e21cc | ||
|
|
5f4bb00007 | ||
|
|
a7f1eb4c25 | ||
|
|
be5fb304d4 | ||
|
|
fdd67a875b | ||
|
|
39940b717a | ||
|
|
67b94c7906 | ||
|
|
eeff9eb121 | ||
|
|
c983d00437 | ||
|
|
7bb3fe6b5a | ||
|
|
d202a2fba4 | ||
|
|
72ea38ca4f | ||
|
|
f96d00d9d1 | ||
|
|
f5b98b32f2 | ||
|
|
8aee2a442e | ||
|
|
12eaf2cc75 | ||
|
|
7194743a30 |
@@ -40,7 +40,6 @@ drm-wake = ["drm"]
|
||||
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
||||
unix-file-copy-paste = [
|
||||
"dep:x11-clipboard",
|
||||
"dep:x11rb",
|
||||
"dep:percent-encoding",
|
||||
"dep:once_cell",
|
||||
"clipboard/unix-file-copy-paste",
|
||||
@@ -191,7 +190,7 @@ evdev = { git="https://github.com/rustdesk-org/evdev" }
|
||||
dbus = "0.9"
|
||||
dbus-crossroads = "0.5"
|
||||
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
||||
x11rb = {version = "0.12", features = ["all-extensions"]}
|
||||
percent-encoding = {version = "2.3", optional = true}
|
||||
once_cell = {version = "1.18", optional = true}
|
||||
nix = { version = "0.29", features = ["term", "process"]}
|
||||
|
||||
@@ -1101,22 +1101,54 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
final s = c.scale;
|
||||
// CanvasModel caches the DPR and only refreshes it when the view style
|
||||
// changes, so read it live to follow the window across monitors.
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
|
||||
bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive;
|
||||
bool isViewScaled() =>
|
||||
c.viewStyle.style == kRemoteViewStyleAdaptive ||
|
||||
c.viewStyle.style == kRemoteViewStyleCustom;
|
||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
|
||||
mouseRegion({child}) => Obx(() {
|
||||
double getCursorScale() {
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
// Predefined artwork must not inherit the cached remote bitmap's DPI.
|
||||
final cache = keyboardEnabled.isTrue
|
||||
? cursor.cache ?? preDefaultCursor.cache
|
||||
: preForbiddenCursor.cache;
|
||||
final peerDpr = cache?.pixelRatio ?? 0;
|
||||
if (isViewScaled() && zoomCursor.isFalse && peerDpr > 0 &&
|
||||
(!isWeb || widget.ffi.ffiModel.pi.platform == kPeerPlatformMacOS)) {
|
||||
// Retina export is physical-sized; Web must undo that change too.
|
||||
// Other Web host bitmaps retain their existing sizing policy.
|
||||
// Adaptive/Custom scales the video, but Zoom cursor is off: preserve the
|
||||
// cursor's logical size instead of multiplying it by the canvas scale.
|
||||
// Divide by the source bitmap density to obtain logical cursor pixels.
|
||||
// Windows expects a physical-pixel scale at this call boundary, hence dpr;
|
||||
// buildCursorOfCache() converts it back to logical scale for the plugin.
|
||||
return (isWindows ? dpr : 1.0) / peerDpr;
|
||||
}
|
||||
// Density metadata is optional. Keep the legacy path for hosts that
|
||||
// omit it so capture-backend upgrades are not a client prerequisite.
|
||||
final imageScale = isViewScaled() && zoomCursor.isTrue
|
||||
? _cursorImageScale(widget.ffi, cursor, useLocalPointer: true)
|
||||
: s;
|
||||
var cursorScale = 1.0;
|
||||
if (isWindows) {
|
||||
// debug win10
|
||||
if (zoomCursor.value && isViewAdaptive()) {
|
||||
cursorScale = s * c.devicePixelRatio;
|
||||
if (zoomCursor.value && isViewScaled()) {
|
||||
cursorScale = imageScale * dpr;
|
||||
}
|
||||
} else {
|
||||
if (zoomCursor.value || isViewOriginal()) {
|
||||
cursorScale = s;
|
||||
cursorScale = imageScale;
|
||||
} else if (!isWeb) {
|
||||
// NSCursor and GdkCursor treat the bitmap size as logical
|
||||
// pixels, so an unzoomed cursor must be shrunk by the DPR to
|
||||
// keep 1 remote px == 1 physical px, the size Original view
|
||||
// already renders it at.
|
||||
cursorScale = 1.0 / dpr;
|
||||
}
|
||||
}
|
||||
return cursorScale;
|
||||
@@ -1149,6 +1181,7 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
child: child);
|
||||
});
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||
_syncScrollAfterLayout(c);
|
||||
final paintWidth = c.getDisplayWidth() * s;
|
||||
final paintHeight = c.getDisplayHeight() * s;
|
||||
final paintSize = Size(paintWidth, paintHeight);
|
||||
@@ -1193,6 +1226,25 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a refresh after layout to keep the painted cursor aligned with video.
|
||||
///
|
||||
/// Viewport, view scale or DPR changes can clamp or detach scroll positions
|
||||
/// without a ScrollNotification, so read the resulting metrics after layout.
|
||||
/// Save this build's fractions: a delayed refresh may update the model without
|
||||
/// notifying, leaving the cursor painted from older values. CanvasModel compares
|
||||
/// the refreshed fractions with this snapshot and notifies only if they differ,
|
||||
/// repairing the cursor position without creating a rebuild loop.
|
||||
void _syncScrollAfterLayout(CanvasModel canvas) {
|
||||
// Custom scrollbars also paint from scroll fractions; preserve Original's path.
|
||||
if (canvas.scrollStyle != ScrollStyle.scrolledge &&
|
||||
canvas.viewStyle.style != kRemoteViewStyleCustom) return;
|
||||
final renderedScroll = (canvas.scrollX, canvas.scrollY);
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
canvas.updateScrollAfterLayout(renderedScroll);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildScrollbarNonTextureRender(
|
||||
ImageModel m, Size imageSize, double s) {
|
||||
return CustomPaint(
|
||||
@@ -1384,43 +1436,165 @@ class CursorPaint extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
double cx = c.x;
|
||||
double cy = c.y;
|
||||
if (c.viewStyle.style == kRemoteViewStyleOriginal &&
|
||||
c.scrollStyle == ScrollStyle.scrollbar) {
|
||||
final imageOffset = _softwareImageOffset(c);
|
||||
double cx = imageOffset?.dx ?? c.x;
|
||||
double cy = imageOffset?.dy ?? c.y;
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||
final rect = c.parent.target!.ffiModel.rect;
|
||||
if (rect == null) {
|
||||
// unreachable!
|
||||
debugPrint('unreachable! The displays rect is null.');
|
||||
return Container();
|
||||
}
|
||||
if (cx < 0) {
|
||||
final imageWidth = rect.width * c.scale;
|
||||
cx = -imageWidth * c.scrollX;
|
||||
}
|
||||
if (cy < 0) {
|
||||
final imageHeight = rect.height * c.scale;
|
||||
cy = -imageHeight * c.scrollY;
|
||||
}
|
||||
// Scrollbar and edge scrolling share a layout that ignores canvas pan offsets.
|
||||
final imageWidth = rect.width * c.scale;
|
||||
final imageHeight = rect.height * c.scale;
|
||||
// Match the integer centering in _buildCrossScrollbarFromLayout.
|
||||
cx = (c.size.width > imageWidth ? (c.size.width - imageWidth) ~/ 2 : 0) -
|
||||
imageWidth * c.scrollX;
|
||||
cy = (c.size.height > imageHeight ? (c.size.height - imageHeight) ~/ 2 : 0) -
|
||||
imageHeight * c.scrollY;
|
||||
}
|
||||
|
||||
double x = (m.x - hotx) * c.scale + cx;
|
||||
double y = (m.y - hoty) * c.scale + cy;
|
||||
double scale = 1.0;
|
||||
final isViewOriginal = c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
if (zoomCursor.value || isViewOriginal) {
|
||||
x = m.x - hotx + cx / c.scale;
|
||||
y = m.y - hoty + cy / c.scale;
|
||||
scale = c.scale;
|
||||
final image = m.image ?? preDefaultCursor.image;
|
||||
// Match native registration's logical minimum for density-aware scaled views.
|
||||
final logicalMinimum = (m.cache?.pixelRatio ?? 0) > 0 &&
|
||||
c.viewStyle.style != kRemoteViewStyleOriginal;
|
||||
final nativePixels = isWindows && !logicalMinimum
|
||||
? MediaQuery.devicePixelRatioOf(context) : 1.0;
|
||||
// Show remote cursor follows the image scale, independently of Zoom cursor.
|
||||
double scale = _cursorImageScale(c.parent.target!, m);
|
||||
if (image != null && (logicalMinimum || scale * nativePixels != 1.0)) {
|
||||
final sx = kMinCursorSize / (image.width * nativePixels);
|
||||
final sy = kMinCursorSize / (image.height * nativePixels);
|
||||
// Preserve Original's short-edge minimum; scaled views use the long edge.
|
||||
final minimumScale = c.viewStyle.style == kRemoteViewStyleOriginal
|
||||
? (sx > sy ? sx : sy)
|
||||
: (sx < sy ? sx : sy);
|
||||
if (scale < minimumScale) scale = minimumScale;
|
||||
}
|
||||
// Drawing origin = (remote cursor position * canvas scale + video origin)
|
||||
// / cursor image scale - source hotspot.
|
||||
// ImagePainter scales both the drawing origin and the artwork, so this
|
||||
// keeps the hotspot at the displayed remote position even if the cursor
|
||||
// scale differs from the canvas scale, e.g. because of the minimum size.
|
||||
final x = (m.x * c.scale + cx) / scale - hotx;
|
||||
final y = (m.y * c.scale + cy) / scale - hoty;
|
||||
|
||||
return CustomPaint(
|
||||
painter: ImagePainter(
|
||||
image: m.image ?? preDefaultCursor.image,
|
||||
image: image,
|
||||
x: x,
|
||||
y: y,
|
||||
scale: scale,
|
||||
useIntegerPosition: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the renderer-adjusted video origin in local logical pixels,
|
||||
/// keeping the painted cursor aligned with the video.
|
||||
///
|
||||
/// Software rendering truncates the origin in image coordinates before
|
||||
/// scaling it back. Linux texture rendering truncates it directly in logical
|
||||
/// pixels. Match that rounding without rounding the cursor or its hotspot.
|
||||
///
|
||||
/// Returns null for overflowing scrollbar/edge-scroll layouts, whose origin
|
||||
/// the caller computes separately, and for other texture renderers, which
|
||||
/// use canvas.x/y unchanged.
|
||||
///
|
||||
/// Example: canvas.x = 10.75 and video scale = 0.5 produce a software-rendered
|
||||
/// origin of (10.75 / 0.5).toInt() * 0.5 = 10.5. Using that same origin for
|
||||
/// the cursor avoids a 0.25-logical-pixel alignment error.
|
||||
Offset? _softwareImageOffset(CanvasModel canvas) {
|
||||
if (canvas.imageOverflow.isTrue &&
|
||||
canvas.scrollStyle != ScrollStyle.scrollauto) {
|
||||
return null;
|
||||
}
|
||||
final ffi = canvas.parent.target!;
|
||||
final peer = ffi.ffiModel;
|
||||
if (ffi.imageModel.useTextureRender || peer.pi.forceTextureRender) {
|
||||
// Match Linux's texture origin without rounding the cursor or hotspot.
|
||||
return isLinux
|
||||
? Offset(canvas.x.toInt().toDouble(), canvas.y.toInt().toDouble())
|
||||
: null;
|
||||
}
|
||||
var scale = canvas.scale;
|
||||
final displays = peer.pi.getCurDisplays();
|
||||
if (peer.isPeerLinux && displays.isNotEmpty) scale /= displays[0].scale;
|
||||
// Match the origin used by _buildScrollAutoNonTextureRender's ImagePainter.
|
||||
return Offset(
|
||||
(canvas.x / scale).toInt() * scale, (canvas.y / scale).toInt() * scale);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Returns the base cursor artwork scale needed to match the remote video.
|
||||
///
|
||||
/// The result is Flutter logical pixels per source cursor bitmap pixel.
|
||||
/// Callers handle minimum-size clamping, native-platform DPR conversion,
|
||||
/// rasterization, and hotspot positioning separately. This function does not
|
||||
/// draw the cursor or transform its remote position.
|
||||
///
|
||||
/// Callers decide whether the cursor should follow the video:
|
||||
/// - [CursorPaint] uses this scale regardless of the Zoom cursor setting.
|
||||
/// - The MouseRegion cursor uses it in Adaptive/Custom view only when Zoom
|
||||
/// cursor is enabled. With Zoom cursor disabled, a separate sizing policy
|
||||
/// keeps the native cursor independent of video zoom.
|
||||
/// Selecting Adaptive/Custom view alone does not imply cursor zoom.
|
||||
///
|
||||
/// For Linux hosts, desktop coordinates and captured bitmap pixels can use
|
||||
/// different scales. The video renderer normally converts bitmap pixels to
|
||||
/// view coordinates using `canvas.scale / display.scale`; cursor artwork
|
||||
/// must use the same conversion. The non-texture scrollbar/edge-scroll
|
||||
/// renderer is an exception: it paints directly at `canvas.scale`.
|
||||
/// Non-Linux hosts also use `canvas.scale` directly.
|
||||
///
|
||||
/// With multiple viewed Linux displays, [useLocalPointer] selects which
|
||||
/// position determines the relevant display. When true, prefer the remote
|
||||
/// coordinates mapped from local mouse input, so native cursor sizing updates
|
||||
/// on display crossings without waiting for a host-position echo. When false,
|
||||
/// use [cursor]'s host-reported position, as required for the painted overlay.
|
||||
/// Before the first mapped local position, both paths use [cursor].
|
||||
/// If no display can be selected, fall back to `canvas.scale`.
|
||||
///
|
||||
/// Do not substitute cursor-bitmap density (`CursorData.pixelRatio`) for
|
||||
/// `display.scale`: this helper follows video geometry, not unzoomed cursor
|
||||
/// DPI normalization. A high-DPI capture path can still use physical desktop
|
||||
/// coordinates, so cursor density alone does not determine the video scale.
|
||||
///
|
||||
/// For example, with Linux `display.scale == 2` and `canvas.scale == 0.5`,
|
||||
/// the usual rendering path returns 0.25: a 64-pixel cursor is drawn 16 logical
|
||||
/// pixels wide, before any minimum-size adjustment.
|
||||
double _cursorImageScale(FFI ffi, CursorModel cursor,
|
||||
{bool useLocalPointer = false}) {
|
||||
final canvas = ffi.canvasModel;
|
||||
final peer = ffi.ffiModel;
|
||||
if (!peer.isPeerLinux) return canvas.scale;
|
||||
if (canvas.imageOverflow.isTrue &&
|
||||
canvas.scrollStyle != ScrollStyle.scrollauto &&
|
||||
!ffi.imageModel.useTextureRender &&
|
||||
!peer.pi.forceTextureRender) {
|
||||
return canvas.scale; // The nontexture scrollbar also paints physical pixels.
|
||||
}
|
||||
final displays = peer.pi.getCurDisplays();
|
||||
if (displays.length == 1) return canvas.scale / displays.first.scale;
|
||||
final rect = peer.rect;
|
||||
if (rect != null) {
|
||||
// Mapped local input is already in host desktop coordinates. CursorModel's
|
||||
// position is relative to rect, so restore its origin for display lookup.
|
||||
// Before the first local movement, both paths use the host position.
|
||||
final position =
|
||||
(useLocalPointer ? ffi.inputModel.remotePointerPosition.value : null) ??
|
||||
Offset(cursor.x + rect.left, cursor.y + rect.top);
|
||||
for (final display in displays) {
|
||||
// Display origins are desktop coordinates; dimensions are physical pixels.
|
||||
// Convert the dimensions so these bounds use the same units as position.
|
||||
if (Rect.fromLTWH(display.x, display.y, display.width / display.scale,
|
||||
display.height / display.scale).contains(position)) {
|
||||
return canvas.scale / display.scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
return canvas.scale;
|
||||
}
|
||||
|
||||
@@ -449,6 +449,11 @@ class InputModel {
|
||||
final isPhysicalMouse = false.obs;
|
||||
int _lastButtons = 0;
|
||||
Offset lastMousePos = Offset.zero;
|
||||
// Latest local pointer position mapped to absolute remote desktop coordinates.
|
||||
// Used to select the native cursor's display scale in Linux All Displays mode,
|
||||
// without waiting for a host position update. Keep it separate from the
|
||||
// host-reported position used to paint the remote cursor.
|
||||
final remotePointerPosition = Rxn<Offset>();
|
||||
int _lastWheelTsUs = 0;
|
||||
|
||||
// Wheel acceleration thresholds.
|
||||
@@ -1954,7 +1959,7 @@ class InputModel {
|
||||
canvasModel.updateLocalCursor(x, y);
|
||||
}
|
||||
|
||||
return _handlePointerDevicePos(
|
||||
final point = _handlePointerDevicePos(
|
||||
kind,
|
||||
x,
|
||||
y,
|
||||
@@ -1965,6 +1970,20 @@ class InputModel {
|
||||
onExit: onExit,
|
||||
buttons: buttons,
|
||||
);
|
||||
_rememberRemotePointer(point, isMove);
|
||||
return point;
|
||||
}
|
||||
|
||||
void _rememberRemotePointer(Point? point, bool isMove) {
|
||||
final peer = parent.target!.ffiModel;
|
||||
if (point == null ||
|
||||
!isMove ||
|
||||
!(isDesktop || isWebDesktop) ||
|
||||
peer.pi.currentDisplay != kAllDisplayValue ||
|
||||
!peer.isPeerLinux) {
|
||||
return;
|
||||
}
|
||||
remotePointerPosition.value = Offset(point.x.toDouble(), point.y.toDouble());
|
||||
}
|
||||
|
||||
bool _isInCurrentWindow(double x, double y) {
|
||||
|
||||
@@ -2696,10 +2696,20 @@ class CanvasModel with ChangeNotifier {
|
||||
|
||||
setScrollPercent(scrollPixelPercent.x, scrollPixelPercent.y);
|
||||
pushScrollPositionToUI(scrollPixel.x, scrollPixel.y);
|
||||
// A no-op jump emits no notification to refresh the actual scroll fractions.
|
||||
updateScrollPercent();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateScrollAfterLayout((double, double) renderedScroll) {
|
||||
updateScrollPercent();
|
||||
// A delayed refresh may already have changed the model without repainting.
|
||||
if (renderedScroll != (_scrollX, _scrollY)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
panX(double dx) {
|
||||
_x += dx;
|
||||
if (isMobile) {
|
||||
@@ -2850,11 +2860,27 @@ class CanvasModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// data for cursor
|
||||
// Bound integer cache keys and raster allocation: 4096 squared RGBA is 64 MiB.
|
||||
const _maxCursorRasterSize = 4096;
|
||||
|
||||
bool _validCursorRasterSize(double width, double height,
|
||||
{double rasterScale = 1}) =>
|
||||
width.isFinite && height.isFinite && rasterScale.isFinite &&
|
||||
width > 0 && height > 0 && rasterScale > 0 &&
|
||||
width.ceilToDouble() * rasterScale <= _maxCursorRasterSize &&
|
||||
height.ceilToDouble() * rasterScale <= _maxCursorRasterSize;
|
||||
|
||||
// Scale the host's bitmap and hotspot together. Incorrect source geometry
|
||||
// must be fixed in host capture, independently of the client's sizing policy.
|
||||
class CursorData {
|
||||
final String peerId;
|
||||
final String id;
|
||||
final img2.Image image;
|
||||
// Borrowed from CursorModel/PredefinedCursor, which own its lifetime.
|
||||
// The plugin clones the handle before starting asynchronous encoding.
|
||||
final ui.Image nativeImage;
|
||||
// Zero preserves legacy sizing for capture backends without density metadata.
|
||||
final double pixelRatio;
|
||||
double scale;
|
||||
Uint8List? data;
|
||||
final double hotxOrigin;
|
||||
@@ -2868,6 +2894,8 @@ class CursorData {
|
||||
required this.peerId,
|
||||
required this.id,
|
||||
required this.image,
|
||||
required this.nativeImage,
|
||||
this.pixelRatio = 0,
|
||||
required this.scale,
|
||||
required this.data,
|
||||
required this.hotxOrigin,
|
||||
@@ -2879,12 +2907,21 @@ class CursorData {
|
||||
|
||||
int _doubleToInt(double v) => (v * 10e6).round().toInt();
|
||||
|
||||
double _checkUpdateScale(double scale) {
|
||||
double oldScale = this.scale;
|
||||
if (scale != 1.0) {
|
||||
// Keep the minimum-size policy here. Native callers let the plugin rasterize
|
||||
// the original ui.Image; Web keeps the encoded-image resizing path.
|
||||
double? _validatedScale(double scale,
|
||||
{required bool useLegacyMinimum, required double rasterScale}) {
|
||||
if (!scale.isFinite || scale <= 0) {
|
||||
debugPrint('Rejected cursor $id: invalid scale $scale');
|
||||
return null;
|
||||
}
|
||||
if (!useLegacyMinimum) {
|
||||
scale = max(scale, kMinCursorSize / max(width, height));
|
||||
}
|
||||
if (useLegacyMinimum && scale != 1.0) {
|
||||
// Update data if scale changed.
|
||||
final tgtWidth = (width * scale).toInt();
|
||||
final tgtHeight = (width * scale).toInt();
|
||||
final tgtWidth = width * scale;
|
||||
final tgtHeight = height * scale;
|
||||
if (tgtWidth < kMinCursorSize || tgtHeight < kMinCursorSize) {
|
||||
double sw = kMinCursorSize.toDouble() / width;
|
||||
double sh = kMinCursorSize.toDouble() / height;
|
||||
@@ -2892,7 +2929,21 @@ class CursorData {
|
||||
}
|
||||
}
|
||||
|
||||
if (_doubleToInt(oldScale) != _doubleToInt(scale)) {
|
||||
if (!_validCursorRasterSize(width * scale, height * scale,
|
||||
rasterScale: rasterScale)) {
|
||||
debugPrint('Rejected cursor $id: raster ${width * scale}x${height * scale} '
|
||||
'at pixel ratio $rasterScale exceeds $_maxCursorRasterSize');
|
||||
return null;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
double _checkUpdateScale(double scale, {required bool resizeImage}) {
|
||||
double oldScale = this.scale;
|
||||
// Web's long-edge minimum can round a thin axis below one raster pixel.
|
||||
final webWidth = max(1, (width * scale).round());
|
||||
final webHeight = max(1, (height * scale).round());
|
||||
if (resizeImage && _doubleToInt(oldScale) != _doubleToInt(scale)) {
|
||||
if (isWindows) {
|
||||
data = img2
|
||||
.copyResize(
|
||||
@@ -2907,8 +2958,8 @@ class CursorData {
|
||||
img2.encodePng(
|
||||
img2.copyResize(
|
||||
image,
|
||||
width: (width * scale).toInt(),
|
||||
height: (height * scale).toInt(),
|
||||
width: isWeb ? webWidth : (width * scale).toInt(),
|
||||
height: isWeb ? webHeight : (height * scale).toInt(),
|
||||
interpolation: img2.Interpolation.average,
|
||||
),
|
||||
),
|
||||
@@ -2919,12 +2970,22 @@ class CursorData {
|
||||
this.scale = scale;
|
||||
hotx = hotxOrigin * scale;
|
||||
hoty = hotyOrigin * scale;
|
||||
if (isWeb) {
|
||||
// CSS hotspots must follow the actual rounded PNG dimensions.
|
||||
hotx = hotxOrigin * webWidth / width;
|
||||
hoty = hotyOrigin * webHeight / height;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
String updateGetKey(double scale) {
|
||||
scale = _checkUpdateScale(scale);
|
||||
return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}';
|
||||
String? updateGetKey(double scale,
|
||||
{bool resizeImage = true, bool useLegacyMinimum = true,
|
||||
double rasterScale = 1}) {
|
||||
final effectiveScale = _validatedScale(scale,
|
||||
useLegacyMinimum: useLegacyMinimum, rasterScale: rasterScale);
|
||||
if (effectiveScale == null) return null;
|
||||
_checkUpdateScale(effectiveScale, resizeImage: resizeImage);
|
||||
return '${peerId}_${id}_${_doubleToInt(width * effectiveScale)}_${_doubleToInt(height * effectiveScale)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2964,7 +3025,8 @@ class PredefinedCursor {
|
||||
CursorData? get cache => _cache;
|
||||
|
||||
init() {
|
||||
_image2 = img2.decodePng(base64Decode(png));
|
||||
final pngBytes = base64Decode(png);
|
||||
_image2 = img2.decodePng(pngBytes);
|
||||
if (_image2 != null) {
|
||||
// The png type of forbidden cursor image is `PngColorType.indexed`.
|
||||
if (id == kPreForbiddenCursorId) {
|
||||
@@ -2972,17 +3034,20 @@ class PredefinedCursor {
|
||||
}
|
||||
|
||||
() async {
|
||||
final defaultImg = _image2!;
|
||||
// This function is called only one time, no need to care about the performance.
|
||||
Uint8List data = defaultImg.getBytes(order: img2.ChannelOrder.rgba);
|
||||
_image?.dispose();
|
||||
_image = await img.decodeImageFromPixels(
|
||||
data, defaultImg.width, defaultImg.height, ui.PixelFormat.rgba8888);
|
||||
if (_image == null) {
|
||||
print("decodeImageFromPixels failed, pre-defined cursor $id");
|
||||
return;
|
||||
// Native registration uses this ui.Image. The RGBA bytes from img2 are
|
||||
// straight alpha, but PixelFormat.rgba8888 requires premultiplied alpha.
|
||||
// Decode the PNG directly to preserve translucent cursor colors.
|
||||
final codec = await ui.instantiateImageCodec(pngBytes);
|
||||
final ui.Image nativeImage;
|
||||
try {
|
||||
nativeImage = (await codec.getNextFrame()).image;
|
||||
} finally {
|
||||
codec.dispose();
|
||||
}
|
||||
_image = nativeImage;
|
||||
double scale = 1.0;
|
||||
final Uint8List data;
|
||||
if (isWindows) {
|
||||
data = _image2!.getBytes(order: img2.ChannelOrder.bgra);
|
||||
} else {
|
||||
@@ -2993,6 +3058,7 @@ class PredefinedCursor {
|
||||
peerId: '',
|
||||
id: id,
|
||||
image: _image2!.clone(),
|
||||
nativeImage: nativeImage,
|
||||
scale: scale,
|
||||
data: data,
|
||||
hotxOrigin:
|
||||
@@ -3428,16 +3494,33 @@ class CursorModel with ChangeNotifier {
|
||||
final hoty = double.parse(evt['hoty']);
|
||||
final width = int.parse(evt['width']);
|
||||
final height = int.parse(evt['height']);
|
||||
final pixelRatio = double.tryParse(evt['scale'] ?? '0');
|
||||
if (pixelRatio == null || !pixelRatio.isFinite || pixelRatio < 0 ||
|
||||
(pixelRatio > 0 &&
|
||||
!_validCursorRasterSize(width / pixelRatio, height / pixelRatio))) {
|
||||
debugPrint('Rejected cursor $id: invalid pixel ratio ${evt['scale']}');
|
||||
return;
|
||||
}
|
||||
List<dynamic> colors = json.decode(evt['colors']);
|
||||
final rgba = Uint8List.fromList(colors.map((s) => s as int).toList());
|
||||
final image = await img.decodeImageFromPixels(
|
||||
rgba, width, height, ui.PixelFormat.rgba8888);
|
||||
final ui.Image? image;
|
||||
final platform = parent.target?.ffiModel.pi.platform;
|
||||
if (!isWeb &&
|
||||
(platform == kPeerPlatformMacOS || platform == kPeerPlatformWindows)) {
|
||||
image = await _decodeStraightAlphaCursor(rgba, width, height);
|
||||
} else {
|
||||
image = await img.decodeImageFromPixels(
|
||||
rgba, width, height, ui.PixelFormat.rgba8888);
|
||||
}
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
if (await _updateCache(rgba, image, id, hotx, hoty, width, height)) {
|
||||
if (await _updateCache(rgba, image, id, hotx, hoty, width, height,
|
||||
pixelRatio: pixelRatio)) {
|
||||
_images[id]?.item1.dispose();
|
||||
_images[id] = Tuple3(image, hotx, hoty);
|
||||
} else {
|
||||
image.dispose();
|
||||
}
|
||||
|
||||
// Update last cursor data.
|
||||
@@ -3445,6 +3528,22 @@ class CursorModel with ChangeNotifier {
|
||||
_updateCurData();
|
||||
}
|
||||
|
||||
Future<ui.Image?> _decodeStraightAlphaCursor(
|
||||
Uint8List rgba, int width, int height) async {
|
||||
// macOS and Win32 capture send straight alpha; XFixes/DRM are premultiplied.
|
||||
// Convert a copy for native ui.Image, preserving the wire and PNG cache colors.
|
||||
final source = img2.Image.fromBytes(
|
||||
width: width, height: height, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
|
||||
for (final pixel in source) {
|
||||
final opacity = pixel.a / pixel.maxChannelValue;
|
||||
pixel.r = (pixel.r * opacity).round();
|
||||
pixel.g = (pixel.g * opacity).round();
|
||||
pixel.b = (pixel.b * opacity).round();
|
||||
}
|
||||
return img.decodeImageFromPixels(
|
||||
source.getBytes(), width, height, ui.PixelFormat.rgba8888);
|
||||
}
|
||||
|
||||
Future<bool> _updateCache(
|
||||
Uint8List rgba,
|
||||
ui.Image image,
|
||||
@@ -3452,8 +3551,9 @@ class CursorModel with ChangeNotifier {
|
||||
double hotx,
|
||||
double hoty,
|
||||
int w,
|
||||
int h,
|
||||
) async {
|
||||
int h, {
|
||||
required double pixelRatio,
|
||||
}) async {
|
||||
Uint8List? data;
|
||||
img2.Image imgOrigin = img2.Image.fromBytes(
|
||||
width: w, height: h, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
|
||||
@@ -3463,6 +3563,7 @@ class CursorModel with ChangeNotifier {
|
||||
ByteData? imgBytes =
|
||||
await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (imgBytes == null) {
|
||||
debugPrint('Unable to encode cursor $id as PNG');
|
||||
return false;
|
||||
}
|
||||
data = imgBytes.buffer.asUint8List();
|
||||
@@ -3471,6 +3572,8 @@ class CursorModel with ChangeNotifier {
|
||||
peerId: peerId,
|
||||
id: id,
|
||||
image: imgOrigin,
|
||||
nativeImage: image,
|
||||
pixelRatio: pixelRatio,
|
||||
scale: 1.0,
|
||||
data: data,
|
||||
hotxOrigin: hotx,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_custom_cursor/cursor_manager.dart'
|
||||
as custom_cursor_manager;
|
||||
import 'package:flutter_custom_cursor/flutter_custom_cursor.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show WidgetsBinding;
|
||||
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
|
||||
deleteCustomCursor(String key) =>
|
||||
@@ -16,29 +22,49 @@ MouseCursor buildCursorOfCache(
|
||||
if (cache == null) {
|
||||
return MouseCursor.defer;
|
||||
} else {
|
||||
final key = cache.updateGetKey(scale);
|
||||
// Include the live DPR so moving between monitors rebuilds the native
|
||||
// bitmap even when the remote view scale has not changed.
|
||||
final dpr = WidgetsBinding
|
||||
.instance.platformDispatcher.views.single.devicePixelRatio;
|
||||
// Keep Original and older peers unchanged. A long-edge minimum preserves
|
||||
// the proportions of thin remote cursors when normalizing their DPI.
|
||||
final legacyMinimum = cache.pixelRatio == 0 ||
|
||||
cursor.parent.target?.canvasModel.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
// The minimum is logical, while Windows callers pass a physical scale.
|
||||
final effectiveScale = !legacyMinimum && isWindows
|
||||
? math.max(scale, kMinCursorSize * dpr / math.max(cache.width, cache.height))
|
||||
: scale;
|
||||
final cacheKey = cache.updateGetKey(effectiveScale, resizeImage: false,
|
||||
useLegacyMinimum: legacyMinimum,
|
||||
rasterScale: isWindows ? 1 : (isLinux ? dpr.ceilToDouble() : dpr));
|
||||
if (cacheKey == null) return MouseCursor.defer;
|
||||
final key = '${cacheKey}_$dpr';
|
||||
if (!cursor.cachedKeys.contains(key)) {
|
||||
// data should be checked here, because it may be changed after `updateGetKey()`
|
||||
final data = cache.data;
|
||||
if (data == null) {
|
||||
return MouseCursor.defer;
|
||||
}
|
||||
debugPrint(
|
||||
"Register custom cursor with key $key (${cache.hotx},${cache.hoty})");
|
||||
// [Safety]
|
||||
// It's ok to call async registerCursor in current synchronous context,
|
||||
// because activating the cursor is also an async call and will always
|
||||
// be executed after this.
|
||||
custom_cursor_manager.CursorManager.instance
|
||||
.registerCursor(custom_cursor_manager.CursorData()
|
||||
..name = key
|
||||
..buffer = data
|
||||
..width = (cache.width * cache.scale).toInt()
|
||||
..height = (cache.height * cache.scale).toInt()
|
||||
..hotX = cache.hotx
|
||||
..hotY = cache.hoty);
|
||||
unawaited(custom_cursor_manager.CursorManager.instance
|
||||
.registerCursorImage(
|
||||
name: key,
|
||||
image: cache.nativeImage,
|
||||
hotSpot: Offset(cache.hotxOrigin, cache.hotyOrigin),
|
||||
// Windows callers already express scale in physical pixels.
|
||||
// The plugin takes logical scale and applies DPR during rasterization.
|
||||
scale: isWindows ? cache.scale / dpr : cache.scale,
|
||||
devicePixelRatio: dpr,
|
||||
)
|
||||
.then<void>((_) {}, onError: (Object error, StackTrace stack) {
|
||||
cursor.cachedKeys.remove(key);
|
||||
FlutterError.reportError(FlutterErrorDetails(
|
||||
exception: error,
|
||||
stack: stack,
|
||||
library: 'native cursor',
|
||||
context: ErrorDescription('registering cursor $key')));
|
||||
}));
|
||||
cursor.addKey(key);
|
||||
}
|
||||
return FlutterCustomMemoryImageCursor(key: key);
|
||||
return FlutterCustomMemoryImageCursor(
|
||||
key: key,
|
||||
registrationToken: custom_cursor_manager.CursorManager.instance
|
||||
.registrationTokenFor(key));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,12 +96,14 @@ class ImagePainter extends CustomPainter {
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.scale,
|
||||
this.useIntegerPosition = true,
|
||||
});
|
||||
|
||||
ui.Image? image;
|
||||
double x;
|
||||
double y;
|
||||
double scale;
|
||||
final bool useIntegerPosition;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
@@ -122,8 +124,10 @@ class ImagePainter extends CustomPainter {
|
||||
if (isWeb) {
|
||||
paint.filterQuality = FilterQuality.high;
|
||||
}
|
||||
canvas.drawImage(
|
||||
image!, Offset(x.toInt().toDouble(), y.toInt().toDouble()), paint);
|
||||
final position = useIntegerPosition
|
||||
? Offset(x.toInt().toDouble(), y.toInt().toDouble())
|
||||
: Offset(x, y);
|
||||
canvas.drawImage(image!, position, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:js' as js;
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -52,8 +53,9 @@ class CursorManager {
|
||||
'cursor',
|
||||
jsonEncode({
|
||||
'url': cursorData.url,
|
||||
'hotx': cursorData.hotX.toInt(),
|
||||
'hoty': cursorData.hotY.toInt(),
|
||||
// Rounding must keep the hotspot inside even a one-pixel raster.
|
||||
'hotx': cursorData.hotX.round().clamp(0, cursorData.width - 1),
|
||||
'hoty': cursorData.hotY.round().clamp(0, cursorData.height - 1),
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -104,7 +106,10 @@ MouseCursor buildCursorOfCache(
|
||||
if (cache == null) {
|
||||
return MouseCursor.defer;
|
||||
} else {
|
||||
final key = cache.updateGetKey(scale);
|
||||
// A short-edge minimum can enlarge thin artwork beyond CSS cursor limits.
|
||||
// Keep unzoomed images unchanged and use the long edge when resizing.
|
||||
final key = cache.updateGetKey(scale, useLegacyMinimum: scale == 1.0);
|
||||
if (key == null) return MouseCursor.defer;
|
||||
if (!cursor.cachedKeys.contains(key)) {
|
||||
// data should be checked here, because it may be changed after `updateGetKey()`
|
||||
final data = cache.data;
|
||||
@@ -116,8 +121,8 @@ MouseCursor buildCursorOfCache(
|
||||
CursorManager.instance.registerCursor(CursorData(
|
||||
key: key,
|
||||
url: 'data:image/rgba;base64,${base64Encode(data)}',
|
||||
width: (cache.width * cache.scale).toInt(),
|
||||
height: (cache.height * cache.scale).toInt(),
|
||||
width: max(1, (cache.width * cache.scale).round()),
|
||||
height: max(1, (cache.height * cache.scale).round()),
|
||||
hotX: cache.hotx,
|
||||
hotY: cache.hoty));
|
||||
cursor.addKey(key);
|
||||
|
||||
@@ -522,9 +522,9 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
||||
resolved-ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
||||
url: "https://github.com/rustdesk-org/flutter_custom_cursor"
|
||||
ref: "d2844a7b150fc9f72b17870b0e3e6c3000d4d848"
|
||||
resolved-ref: "d2844a7b150fc9f72b17870b0e3e6c3000d4d848"
|
||||
url: "https://github.com/fufesou/flutter_custom_cursor"
|
||||
source: git
|
||||
version: "0.0.3"
|
||||
flutter_gpu_texture_renderer:
|
||||
|
||||
@@ -62,8 +62,8 @@ dependencies:
|
||||
freezed_annotation: ^2.0.3
|
||||
flutter_custom_cursor:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/flutter_custom_cursor
|
||||
ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
||||
url: https://github.com/fufesou/flutter_custom_cursor
|
||||
ref: d2844a7b150fc9f72b17870b0e3e6c3000d4d848
|
||||
window_size:
|
||||
git:
|
||||
url: https://github.com/21pages/flutter-desktop-embedding.git
|
||||
|
||||
207
flutter/test/cursor_density_validation_test.dart
Normal file
207
flutter/test/cursor_density_validation_test.dart
Normal file
@@ -0,0 +1,207 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_custom_cursor/cursor_manager.dart' show CursorManager;
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/common.dart' as common;
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const _viewport = Size(200, 160);
|
||||
|
||||
class _Image extends ChangeNotifier implements ImageModel {
|
||||
@override
|
||||
bool get useTextureRender => false;
|
||||
@override
|
||||
ui.Image? get image => null;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Canvas extends ChangeNotifier implements CanvasModel {
|
||||
_Canvas(this.devicePixelRatio, {required this.style, required this.scale});
|
||||
|
||||
final String style;
|
||||
|
||||
@override
|
||||
final double devicePixelRatio;
|
||||
@override
|
||||
final imageOverflow = false.obs;
|
||||
@override
|
||||
late final viewStyle = ViewStyle(
|
||||
style: style,
|
||||
width: _viewport.width,
|
||||
height: _viewport.height,
|
||||
displayWidth: 400,
|
||||
displayHeight: 320,
|
||||
);
|
||||
@override
|
||||
bool get cursorEmbedded => false;
|
||||
@override
|
||||
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
|
||||
@override
|
||||
Size get size => _viewport;
|
||||
@override
|
||||
final double scale;
|
||||
@override
|
||||
double get x => 0;
|
||||
@override
|
||||
double get y => 0;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Input extends Fake implements InputModel {
|
||||
@override
|
||||
final relativeMouseMode = false.obs;
|
||||
}
|
||||
|
||||
class _Peer extends Fake implements FfiModel {
|
||||
@override
|
||||
final pi = PeerInfo();
|
||||
@override
|
||||
bool isPeerLinux = false;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
_FFI(this.canvasModel);
|
||||
|
||||
@override
|
||||
final CanvasModel canvasModel;
|
||||
@override
|
||||
final inputModel = _Input();
|
||||
@override
|
||||
final _Peer ffiModel = _Peer();
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
_rasterBoundsTests();
|
||||
final channel = common.isWindows
|
||||
? SystemChannels.mouseCursor
|
||||
: const MethodChannel('flutter_custom_cursor');
|
||||
final registrations = <Map<dynamic, dynamic>>[];
|
||||
setUp(() {
|
||||
registrations.clear();
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||
(call) async {
|
||||
if (!call.method.startsWith('createCustomCursor')) return null;
|
||||
final args = call.arguments as Map<dynamic, dynamic>;
|
||||
registrations.add(args);
|
||||
return args['name'];
|
||||
});
|
||||
});
|
||||
tearDown(() =>
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
|
||||
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||
for (final density in ['0', '1', '2', '1e-300', '0.001']) {
|
||||
testWidgets(
|
||||
'density boundary $style density=$density',
|
||||
(tester) => tester.runAsync(
|
||||
() => checkDensity(tester, (style, density), registrations)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _rasterBoundsTests() {
|
||||
for (final (width, height, scale, legacy, rasterScale) in [
|
||||
(32, 32, 1e300, false, 1.0),
|
||||
(32, 32, 129.0, false, 1.0),
|
||||
(32, 32, 100.0, false, 2.0),
|
||||
(512, 1, 0.1, true, 1.0),
|
||||
]) {
|
||||
test('rejects raster ${width}x$height scale=$scale before key creation',
|
||||
() async {
|
||||
final image = await createTestImage(width: width, height: height);
|
||||
addTearDown(image.dispose);
|
||||
final data = CursorData(
|
||||
peerId: 'bounds',
|
||||
id: 'bounds',
|
||||
image: img.Image(width: width, height: height, numChannels: 4),
|
||||
nativeImage: image,
|
||||
scale: 1,
|
||||
data: null,
|
||||
hotxOrigin: 0,
|
||||
hotyOrigin: 0,
|
||||
width: width,
|
||||
height: height);
|
||||
final initial = data.updateGetKey(1, resizeImage: false);
|
||||
expect(
|
||||
data.updateGetKey(scale,
|
||||
resizeImage: false,
|
||||
useLegacyMinimum: legacy,
|
||||
rasterScale: rasterScale),
|
||||
isNull);
|
||||
expect(data.scale, 1);
|
||||
expect(data.updateGetKey(1, resizeImage: false), initial);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkDensity(WidgetTester tester, (String, String) input,
|
||||
List<Map<dynamic, dynamic>> registrations) async {
|
||||
final (style, density) = input;
|
||||
final id = '$style-$density';
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final canvas = _Canvas(1, style: style, scale: 0.5);
|
||||
final ffi = _FFI(canvas)..ffiModel.pi.platform = kPeerPlatformMacOS;
|
||||
final cursor = CursorModel(WeakReference(ffi))..id = id;
|
||||
addTearDown(() async {
|
||||
expect(cursor.parent.target, same(ffi));
|
||||
for (final key in cursor.cachedKeys) {
|
||||
await deleteCustomCursor(key);
|
||||
}
|
||||
cursor.disposeImages();
|
||||
cursor.dispose();
|
||||
canvas.dispose();
|
||||
});
|
||||
await cursor.updateCursorData(_cursorEvent(id, density));
|
||||
final rejected = density == '1e-300' || density == '0.001';
|
||||
if (rejected) {
|
||||
expect(cursor.cache, isNull,
|
||||
reason: 'Reject density before publishing a cursor');
|
||||
await cursor.updateCursorData(_cursorEvent(id, '2'));
|
||||
}
|
||||
expect(cursor.cache!.pixelRatio, rejected ? 2 : double.parse(density));
|
||||
await tester.pumpWidget(MediaQuery(
|
||||
data: const MediaQueryData(devicePixelRatio: 1),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>(create: (_) => _Image()),
|
||||
ChangeNotifierProvider<CanvasModel>.value(value: canvas),
|
||||
ChangeNotifierProvider<CursorModel>.value(value: cursor),
|
||||
],
|
||||
child: ImagePaint(
|
||||
ffi: ffi,
|
||||
id: 'density-review',
|
||||
zoomCursor: false.obs,
|
||||
cursorOverImage: true.obs,
|
||||
keyboardEnabled: true.obs,
|
||||
remoteCursorMoved: false.obs)),
|
||||
));
|
||||
expect(tester.takeException(), isNull);
|
||||
for (final key in cursor.cachedKeys) {
|
||||
await CursorManager.instance.ensureCursorRegistered(key);
|
||||
}
|
||||
expect(registrations, hasLength(1));
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
}
|
||||
|
||||
Map<String, String> _cursorEvent(String id, String density) => {
|
||||
'id': id,
|
||||
'width': '32',
|
||||
'height': '32',
|
||||
'hotx': '8',
|
||||
'hoty': '12',
|
||||
'scale': density,
|
||||
'colors': jsonEncode(List<int>.filled(32 * 32 * 4, 255)),
|
||||
};
|
||||
307
flutter/test/cursor_dpi_policy_test.dart
Normal file
307
flutter/test/cursor_dpi_policy_test.dart
Normal file
@@ -0,0 +1,307 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_custom_cursor/cursor_manager.dart' show CursorManager;
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const _viewport = Size(200, 160);
|
||||
|
||||
class _Image extends ChangeNotifier implements ImageModel {
|
||||
@override
|
||||
bool get useTextureRender => false;
|
||||
@override
|
||||
ui.Image? get image => null;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Canvas extends ChangeNotifier implements CanvasModel {
|
||||
_Canvas(this.devicePixelRatio, {required this.style, required this.scale});
|
||||
|
||||
final String style;
|
||||
|
||||
@override
|
||||
final double devicePixelRatio;
|
||||
@override
|
||||
final imageOverflow = false.obs;
|
||||
@override
|
||||
late final viewStyle = ViewStyle(
|
||||
style: style,
|
||||
width: _viewport.width,
|
||||
height: _viewport.height,
|
||||
displayWidth: 400,
|
||||
displayHeight: 320,
|
||||
);
|
||||
@override
|
||||
bool get cursorEmbedded => false;
|
||||
@override
|
||||
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
|
||||
@override
|
||||
Size get size => _viewport;
|
||||
@override
|
||||
final double scale;
|
||||
@override
|
||||
double get x => 0;
|
||||
@override
|
||||
double get y => 0;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Cursor extends CursorModel {
|
||||
_Cursor(this.cache, this._ffi) : super(WeakReference(_ffi));
|
||||
|
||||
final FFI _ffi;
|
||||
@override
|
||||
WeakReference<FFI> get parent => WeakReference(_ffi);
|
||||
|
||||
@override
|
||||
CursorData cache;
|
||||
@override
|
||||
double get hotx => cache.hotxOrigin;
|
||||
@override
|
||||
double get hoty => cache.hotyOrigin;
|
||||
}
|
||||
|
||||
class _Input extends Fake implements InputModel {
|
||||
@override
|
||||
final relativeMouseMode = false.obs;
|
||||
}
|
||||
|
||||
class _Peer extends Fake implements FfiModel {
|
||||
@override
|
||||
final pi = PeerInfo();
|
||||
@override
|
||||
bool isPeerLinux = false;
|
||||
}
|
||||
|
||||
class _LinuxDisplay extends Display {
|
||||
@override
|
||||
double get scale => 2;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
_FFI(this.canvasModel);
|
||||
|
||||
@override
|
||||
final CanvasModel canvasModel;
|
||||
@override
|
||||
final inputModel = _Input();
|
||||
@override
|
||||
final _Peer ffiModel = _Peer();
|
||||
}
|
||||
|
||||
Future<CursorData> _data(int density, String id) async {
|
||||
final bitmapDensity = density == 0 ? 1 : density;
|
||||
final image = await createTestImage(
|
||||
width: 9 * bitmapDensity, height: 18 * bitmapDensity);
|
||||
return CursorData(
|
||||
peerId: 'dpi-policy',
|
||||
id: id,
|
||||
image: img.Image(width: image.width, height: image.height, numChannels: 4),
|
||||
nativeImage: image,
|
||||
scale: 1,
|
||||
data: Uint8List.fromList([1, 2]),
|
||||
hotxOrigin: 4.0 * bitmapDensity,
|
||||
hotyOrigin: 9.0 * bitmapDensity,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
pixelRatio: density.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final view = binding.platformDispatcher.views.single;
|
||||
final channel = Platform.isWindows
|
||||
? SystemChannels.mouseCursor
|
||||
: const MethodChannel('flutter_custom_cursor');
|
||||
final windows = Platform.isWindows;
|
||||
final registrations = <Map<dynamic, dynamic>>[];
|
||||
setUp(() {
|
||||
registrations.clear();
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||
(call) async {
|
||||
if (!call.method.startsWith('createCustomCursor')) return null;
|
||||
final args = call.arguments as Map<dynamic, dynamic>;
|
||||
registrations.add(args);
|
||||
return args['name'];
|
||||
});
|
||||
});
|
||||
tearDown(() {
|
||||
view.resetDevicePixelRatio();
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
for (final forbidden in [false, true]) {
|
||||
test('predefined cursor forbidden=$forbidden preserves native RGBA', () {
|
||||
view.devicePixelRatio = 1;
|
||||
return _checkPredefinedCursor(
|
||||
forbidden ? preForbiddenCursor : preDefaultCursor, registrations);
|
||||
});
|
||||
}
|
||||
for (final testCase in [
|
||||
(kRemoteViewStyleAdaptive, false, 0, 0.25, windows ? 1.0 : 4 / 3),
|
||||
(kRemoteViewStyleCustom, false, 0, 0.25, windows ? 1.0 : 4 / 3),
|
||||
(kRemoteViewStyleAdaptive, false, 1, 0.25, windows ? 2.0 : 1.0),
|
||||
(kRemoteViewStyleAdaptive, false, 2, 0.25, windows ? 1.0 : 0.5),
|
||||
(kRemoteViewStyleCustom, false, 2, 0.25, windows ? 1.0 : 0.5),
|
||||
(kRemoteViewStyleAdaptive, true, 2, 0.25, windows ? 2 / 3 : 1 / 3),
|
||||
(kRemoteViewStyleCustom, true, 2, 0.25, windows ? 2 / 3 : 1 / 3),
|
||||
(kRemoteViewStyleOriginal, false, 2, 0.5, windows ? 1.0 : 2 / 3),
|
||||
]) {
|
||||
testWidgets(
|
||||
'${testCase.$1} zoom=${testCase.$2} peerDPR=${testCase.$3}',
|
||||
(tester) => tester
|
||||
.runAsync(() => _checkPolicy(tester, testCase, registrations)));
|
||||
}
|
||||
test('live DPR changes invalidate a cached native cursor',
|
||||
() => _checkDprChange(view, registrations));
|
||||
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||
testWidgets('$style forbidden cursor ignores remote DPR', (tester) =>
|
||||
tester.runAsync(() => _checkPolicy(tester,
|
||||
(style, false, 2, 0.25, 0.5), registrations, dpr: 1)));
|
||||
testWidgets('Linux $style zoom follows video pixels', (tester) => tester.runAsync(
|
||||
() => _checkPolicy(tester, (style, true, 2, 1.0, windows ? 1.0 : 0.5),
|
||||
registrations, linux: true)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkPolicy(
|
||||
WidgetTester tester,
|
||||
(String, bool, int, double, double) testCase,
|
||||
List<Map<dynamic, dynamic>> registrations,
|
||||
{bool linux = false, double dpr = 2}) async {
|
||||
final (style, zoom, density, viewScale, expectedScale) = testCase;
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
final data = await _data(density, '$style-$zoom-$density');
|
||||
final originalBytes = data.data;
|
||||
// A stale cached DPR must not affect the cursor when the window moves.
|
||||
final canvas = _Canvas(1, style: style, scale: viewScale);
|
||||
final ffi = _FFI(canvas);
|
||||
ffi.ffiModel.isPeerLinux = linux;
|
||||
if (linux) ffi.ffiModel.pi.displays.add(_LinuxDisplay());
|
||||
final cursor = _Cursor(data, ffi);
|
||||
final keyboardEnabled = true.obs;
|
||||
await tester.pumpWidget(MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>(create: (_) => _Image()),
|
||||
ChangeNotifierProvider<CanvasModel>.value(value: canvas),
|
||||
ChangeNotifierProvider<CursorModel>.value(value: cursor),
|
||||
],
|
||||
child: ImagePaint(
|
||||
ffi: ffi,
|
||||
id: 'dpi-policy',
|
||||
zoomCursor: zoom.obs,
|
||||
cursorOverImage: true.obs,
|
||||
keyboardEnabled: keyboardEnabled,
|
||||
remoteCursorMoved: false.obs,
|
||||
)),
|
||||
));
|
||||
final revoke = !zoom && style != kRemoteViewStyleOriginal;
|
||||
if (revoke) {
|
||||
await Future.wait(cursor.cachedKeys
|
||||
.map(CursorManager.instance.ensureCursorRegistered));
|
||||
keyboardEnabled.value = false;
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
for (final key in cursor.cachedKeys) {
|
||||
await deleteCustomCursor(key);
|
||||
}
|
||||
data.nativeImage.dispose();
|
||||
cursor.dispose();
|
||||
canvas.dispose();
|
||||
expect(data.scale, closeTo(expectedScale, 1e-9));
|
||||
expect(data.hotx, closeTo(data.hotxOrigin * expectedScale, 1e-9));
|
||||
expect(data.hoty, closeTo(data.hotyOrigin * expectedScale, 1e-9));
|
||||
expect(data.data, same(originalBytes));
|
||||
if (revoke) {
|
||||
final args = registrations.last;
|
||||
expect(args['name'], contains('_${kPreForbiddenCursorId}_'));
|
||||
expect((args['width'], args['height']), (32, 32));
|
||||
expect((args['hotX'], args['hotY']), (0.0, 0.0));
|
||||
}
|
||||
if (style == kRemoteViewStyleAdaptive && !zoom && density > 0) {
|
||||
final args = registrations.first;
|
||||
expect((args['width'], args['height']), ((Platform.isLinux ? 18 : 9) * dpr, 18 * dpr));
|
||||
expect((args['hotX'], args['hotY']), (4 * dpr, 9 * dpr));
|
||||
expect(args['imagePixelRatio'], dpr);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkDprChange(
|
||||
TestFlutterView view, List<Map<dynamic, dynamic>> registrations) async {
|
||||
// Keep the scale above the minimum so only DPR invalidates the cache.
|
||||
final data = await _data(2, 'dpr-cache');
|
||||
final canvas = _Canvas(1, style: kRemoteViewStyleAdaptive, scale: 1);
|
||||
final cursor = _Cursor(data, _FFI(canvas));
|
||||
for (final dpr in [2.0, 1.0]) {
|
||||
view.devicePixelRatio = dpr;
|
||||
buildCursorOfCache(cursor, 1, data);
|
||||
await deleteCustomCursor(cursor.cachedKeys.last);
|
||||
}
|
||||
expect(registrations, hasLength(2));
|
||||
expect(registrations[0]['name'], isNot(registrations[1]['name']));
|
||||
data.nativeImage.dispose();
|
||||
cursor.dispose();
|
||||
canvas.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkPredefinedCursor(PredefinedCursor predefined,
|
||||
List<Map<dynamic, dynamic>> registrations) async {
|
||||
await Future.doWhile(() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
return predefined.cache == null;
|
||||
}).timeout(const Duration(seconds: 5));
|
||||
final cache = predefined.cache!;
|
||||
final original = img
|
||||
.decodePng(base64Decode(predefined.png))!
|
||||
.convert(format: img.Format.uint8, numChannels: 4);
|
||||
final canvas = _Canvas(1, style: kRemoteViewStyleAdaptive, scale: 1);
|
||||
final cursor = _Cursor(cache, _FFI(canvas));
|
||||
buildCursorOfCache(cursor, 1, cache);
|
||||
await deleteCustomCursor(cursor.cachedKeys.single);
|
||||
cursor.dispose();
|
||||
canvas.dispose();
|
||||
|
||||
final args = registrations.single;
|
||||
final bytes = args['buffer'] as Uint8List;
|
||||
final decoded = Platform.isWindows
|
||||
? img.Image.fromBytes(
|
||||
width: args['width'] as int,
|
||||
height: args['height'] as int,
|
||||
bytes: bytes.buffer,
|
||||
bytesOffset: bytes.offsetInBytes,
|
||||
order: img.ChannelOrder.bgra)
|
||||
: img.decodePng(bytes)!;
|
||||
expect((decoded.width, decoded.height), (original.width, original.height));
|
||||
expect(args['imagePixelRatio'], 1.0);
|
||||
expect((args['hotX'], args['hotY']), (cache.hotxOrigin, cache.hotyOrigin));
|
||||
expect(cache.image.getBytes(), original.getBytes());
|
||||
// Actual bundled pixels cover transparent, opaque and translucent colors.
|
||||
for (final (x, y) in [(0, 0), (1, 8), (13, 22), (16, 16)]) {
|
||||
final expected = original.getPixel(x, y);
|
||||
final actual = decoded.getPixel(x, y);
|
||||
expect(actual.a, expected.a);
|
||||
for (final (got, want) in [
|
||||
(actual.r, expected.r),
|
||||
(actual.g, expected.g),
|
||||
(actual.b, expected.b)
|
||||
]) {
|
||||
expect(got, closeTo(want, 1), reason: '${predefined.id} at ($x, $y)');
|
||||
}
|
||||
}
|
||||
}
|
||||
281
flutter/test/cursor_local_display_test.dart
Normal file
281
flutter/test/cursor_local_display_test.dart
Normal file
@@ -0,0 +1,281 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_custom_cursor/cursor_manager.dart' as cursor_manager;
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
const _viewport = Size(1920, 540);
|
||||
const _id = 'local-display-review';
|
||||
const _side = 64;
|
||||
const _hotspot = Offset(8, 12);
|
||||
|
||||
class _Display extends Display {
|
||||
_Display(this.density, double left, int w, int h) {
|
||||
x = left;
|
||||
width = w;
|
||||
height = h;
|
||||
}
|
||||
final double density;
|
||||
@override
|
||||
double get scale => density;
|
||||
}
|
||||
|
||||
class _Peer extends Fake implements FfiModel {
|
||||
_Peer() {
|
||||
pi.platform = kPeerPlatformLinux;
|
||||
pi.currentDisplay = kAllDisplayValue;
|
||||
pi.displays.assignAll([
|
||||
_Display(2, 0, 3840, 2160),
|
||||
_Display(1, 1920, 1920, 1080),
|
||||
]);
|
||||
}
|
||||
@override
|
||||
final pi = PeerInfo();
|
||||
@override
|
||||
bool get isPeerLinux => true;
|
||||
@override
|
||||
bool get keyboard => true;
|
||||
@override
|
||||
bool get viewOnly => false;
|
||||
@override
|
||||
Rect get rect => const Rect.fromLTWH(0, 0, 3840, 1080);
|
||||
}
|
||||
|
||||
class _Image extends ChangeNotifier implements ImageModel {
|
||||
@override
|
||||
bool get useTextureRender => true;
|
||||
@override
|
||||
ui.Image? get image => null;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Texture extends Fake implements TextureModel {
|
||||
final ids = <int, RxInt>{};
|
||||
@override
|
||||
RxInt getTextureId(int display) =>
|
||||
ids.putIfAbsent(display, () => display.obs);
|
||||
}
|
||||
|
||||
class _Canvas extends CanvasModel {
|
||||
_Canvas(FFI ffi, String style)
|
||||
: viewStyle = ViewStyle(
|
||||
style: style,
|
||||
width: 1920,
|
||||
height: 540,
|
||||
displayWidth: 3840,
|
||||
displayHeight: 1080),
|
||||
super(WeakReference(ffi)) {
|
||||
id = _id;
|
||||
}
|
||||
@override
|
||||
double get scale => 0.5;
|
||||
@override
|
||||
Size get size => _viewport;
|
||||
@override
|
||||
bool get cursorEmbedded => false;
|
||||
@override
|
||||
final ViewStyle viewStyle;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
_FFI(String style) {
|
||||
canvasModel = _Canvas(this, style);
|
||||
cursorModel = CursorModel(WeakReference(this));
|
||||
inputModel = InputModel(WeakReference(this));
|
||||
}
|
||||
@override
|
||||
final sessionId = Uuid().v4obj();
|
||||
@override
|
||||
String get id => _id;
|
||||
@override
|
||||
final ffiModel = _Peer();
|
||||
@override
|
||||
final imageModel = _Image();
|
||||
@override
|
||||
final textureModel = _Texture();
|
||||
@override
|
||||
late final CanvasModel canvasModel;
|
||||
@override
|
||||
late final CursorModel cursorModel;
|
||||
@override
|
||||
late final InputModel inputModel;
|
||||
Offset? mappedPosition;
|
||||
}
|
||||
|
||||
Future<void> _shape(_FFI ffi, String id) async {
|
||||
ffi.cursorModel.id = id;
|
||||
await ffi.cursorModel.updateCursorData({
|
||||
'id': id,
|
||||
'width': '$_side',
|
||||
'height': '$_side',
|
||||
'scale': '2',
|
||||
'hotx': '${_hotspot.dx}',
|
||||
'hoty': '${_hotspot.dy}',
|
||||
'colors': jsonEncode(List.generate(_side * _side * 4, (_) => 255)),
|
||||
});
|
||||
}
|
||||
|
||||
Widget _widget(_FFI ffi, double dpr) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>.value(value: ffi.imageModel),
|
||||
ChangeNotifierProvider<CanvasModel>.value(value: ffi.canvasModel),
|
||||
ChangeNotifierProvider<CursorModel>.value(value: ffi.cursorModel),
|
||||
],
|
||||
child: ImagePaint(
|
||||
ffi: ffi,
|
||||
id: _id,
|
||||
zoomCursor: true.obs,
|
||||
cursorOverImage: true.obs,
|
||||
keyboardEnabled: true.obs,
|
||||
remoteCursorMoved: RemoteCursorMovedState.find(_id),
|
||||
listenerBuilder: (child) => Listener(
|
||||
child: child,
|
||||
onPointerHover: (e) {
|
||||
final point = ffi.inputModel.handlePointerDevicePos(
|
||||
kPointerEventKindMouse,
|
||||
e.position.dx,
|
||||
e.position.dy,
|
||||
true,
|
||||
kMouseEventTypeDefault,
|
||||
moveCanvas: false);
|
||||
if (point != null) {
|
||||
ffi.mappedPosition =
|
||||
Offset(point.x.toDouble(), point.y.toDouble());
|
||||
}
|
||||
})))));
|
||||
|
||||
Future<void> _settle(WidgetTester tester, _FFI ffi) async {
|
||||
await tester.pump();
|
||||
for (final key in ffi.cursorModel.cachedKeys) {
|
||||
await cursor_manager.CursorManager.instance.ensureCursorRegistered(key);
|
||||
}
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final registrations = <Map<dynamic, dynamic>>[];
|
||||
final activations = <String>[];
|
||||
final channel = Platform.isWindows
|
||||
? SystemChannels.mouseCursor
|
||||
: const MethodChannel('flutter_custom_cursor');
|
||||
setUp(() {
|
||||
registrations.clear();
|
||||
activations.clear();
|
||||
RemoteCursorMovedState.init(_id);
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||
(call) async {
|
||||
if (call.method.startsWith('setCustomCursor')) {
|
||||
activations.add(call.arguments['name'] as String);
|
||||
}
|
||||
if (!call.method.startsWith('createCustomCursor')) return null;
|
||||
final args = call.arguments as Map<dynamic, dynamic>;
|
||||
registrations.add(args);
|
||||
return args['name'];
|
||||
});
|
||||
});
|
||||
tearDown(() {
|
||||
RemoteCursorMovedState.delete(_id);
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
for (final (style, dpr) in [
|
||||
(kRemoteViewStyleAdaptive, 1.0),
|
||||
(kRemoteViewStyleCustom, 2.0),
|
||||
]) {
|
||||
for (final mode in ['movement', 'shape refresh', 'host position control']) {
|
||||
testWidgets(
|
||||
'mixed displays: $style DPR=$dpr $mode',
|
||||
(tester) => tester.runAsync(
|
||||
() => _checkMovement(tester, (style, dpr, mode), (registrations, activations))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkMovement(
|
||||
WidgetTester tester,
|
||||
(String, double, String) testCase,
|
||||
(List<Map<dynamic, dynamic>>, List<String>) calls) async {
|
||||
final (style, dpr, mode) = testCase;
|
||||
final (registrations, activations) = calls;
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
tester.view.physicalSize = _viewport * dpr;
|
||||
addTearDown(tester.view.reset);
|
||||
final ffi = _FFI(style);
|
||||
addTearDown(() => _dispose(ffi));
|
||||
await _shape(ffi, '$style-$dpr-$mode-1');
|
||||
await ffi.cursorModel.updateCursorPosition({'x': '100', 'y': '100'}, _id);
|
||||
ffi.canvasModel.updateLocalCursor(50, 50);
|
||||
await tester.pumpWidget(_widget(ffi, dpr));
|
||||
await _settle(tester, ffi);
|
||||
expect(registrations.last['width'], 16 * dpr);
|
||||
await _moveToB(tester, ffi, (mode, activations));
|
||||
final args = registrations.last;
|
||||
expect((args['width'], args['height']), (32 * dpr, 32 * dpr));
|
||||
expect((args['hotX'], args['hotY']), (4 * dpr, 6 * dpr));
|
||||
}
|
||||
|
||||
Future<void> _moveToB(WidgetTester tester, _FFI ffi, (String, List<String>) testCase) async {
|
||||
final (mode, activations) = testCase;
|
||||
final mouse = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
|
||||
await mouse.addPointer(location: const Offset(50, 50));
|
||||
await _settle(tester, ffi);
|
||||
final before = activations.length;
|
||||
for (final x in [60.0, 70.0, 80.0]) {
|
||||
await mouse.moveTo(Offset(x, 50));
|
||||
await _settle(tester, ffi);
|
||||
expect(activations.length, before);
|
||||
}
|
||||
await mouse.moveTo(
|
||||
Offset(1100 + CanvasModel.leftToEdge, 50 + CanvasModel.topToEdge));
|
||||
await _settle(tester, ffi);
|
||||
expect(activations.length, before + 1);
|
||||
expect(ffi.mappedPosition, const Offset(2200, 100));
|
||||
expect(ffi.cursorModel.offset, const Offset(100, 100));
|
||||
if (mode == 'shape refresh') {
|
||||
await _shape(ffi, '$mode-2');
|
||||
} else if (mode == 'host position control') {
|
||||
await ffi.cursorModel.updateCursorPosition({'x': '2200', 'y': '100'}, _id);
|
||||
ffi.canvasModel.updateLocalCursor(1100, 50);
|
||||
}
|
||||
await _settle(tester, ffi);
|
||||
final after = activations.length;
|
||||
expect(after, before + (mode == 'shape refresh' ? 2 : 1));
|
||||
for (final x in [1110.0, 1120.0, 1130.0]) {
|
||||
await mouse.moveTo(Offset(x, 50));
|
||||
await _settle(tester, ffi);
|
||||
expect(activations.length, after);
|
||||
}
|
||||
final displayB = tester.widgetList<Positioned>(find.byType(Positioned)).last;
|
||||
expect(displayB.width! / 1920, 0.5);
|
||||
await mouse.removePointer();
|
||||
}
|
||||
|
||||
Future<void> _dispose(_FFI ffi) async {
|
||||
for (final key in ffi.cursorModel.cachedKeys) {
|
||||
await deleteCustomCursor(key);
|
||||
}
|
||||
ffi.cursorModel.disposeImages();
|
||||
ffi.cursorModel.dispose();
|
||||
ffi.canvasModel.dispose();
|
||||
ffi.imageModel.dispose();
|
||||
// Relative mode is never started and this fixture has no Rust session.
|
||||
ffi.inputModel.disposeSideButtonTracking();
|
||||
}
|
||||
158
flutter/test/cursor_native_alpha_test.dart
Normal file
158
flutter/test/cursor_native_alpha_test.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
const _side = 32;
|
||||
const _hotspot = Offset(7, 9);
|
||||
const _colors = [
|
||||
[64, 32, 16, 128],
|
||||
[240, 100, 20, 255],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 128],
|
||||
];
|
||||
final _stripeWidth = _side ~/ _colors.length;
|
||||
|
||||
class _Canvas extends Fake implements CanvasModel {
|
||||
@override
|
||||
final viewStyle = ViewStyle(
|
||||
style: kRemoteViewStyleAdaptive,
|
||||
width: 200,
|
||||
height: 160,
|
||||
displayWidth: 200,
|
||||
displayHeight: 160);
|
||||
}
|
||||
|
||||
class _Peer extends Fake implements FfiModel {
|
||||
_Peer(String platform) : pi = (PeerInfo()..platform = platform);
|
||||
@override
|
||||
final PeerInfo pi;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
_FFI(String platform) : ffiModel = _Peer(platform);
|
||||
@override
|
||||
final canvasModel = _Canvas();
|
||||
@override
|
||||
final _Peer ffiModel;
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final view = binding.platformDispatcher.views.single;
|
||||
final channel = Platform.isWindows
|
||||
? SystemChannels.mouseCursor
|
||||
: const MethodChannel('flutter_custom_cursor');
|
||||
final registrations = <Map<dynamic, dynamic>>[];
|
||||
setUp(() {
|
||||
registrations.clear();
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||
(call) async {
|
||||
if (!call.method.startsWith('createCustomCursor')) return null;
|
||||
final args = call.arguments as Map<dynamic, dynamic>;
|
||||
registrations.add(args);
|
||||
return args['name'];
|
||||
});
|
||||
});
|
||||
tearDown(() {
|
||||
view.resetDevicePixelRatio();
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
for (final testCase in <(String, String?, List<int>)>[
|
||||
(kPeerPlatformMacOS, null, [64, 32, 16, 128]),
|
||||
(kPeerPlatformMacOS, '0', [64, 32, 16, 128]),
|
||||
(kPeerPlatformMacOS, '1', [64, 32, 16, 128]),
|
||||
(kPeerPlatformMacOS, '2', [64, 32, 16, 128]),
|
||||
(kPeerPlatformWindows, null, [64, 32, 16, 128]),
|
||||
(kPeerPlatformWindows, '0', [64, 32, 16, 128]),
|
||||
(kPeerPlatformLinux, '0', [32, 16, 8, 128]),
|
||||
]) {
|
||||
for (final dpr in [1.0, 2.0]) {
|
||||
test(
|
||||
'native ${testCase.$1} scale=${testCase.$2} DPR=$dpr preserves alpha',
|
||||
() {
|
||||
view.devicePixelRatio = dpr;
|
||||
return _checkCursor(testCase, dpr, registrations);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkCursor((String, String?, List<int>) testCase, double dpr,
|
||||
List<Map<dynamic, dynamic>> registrations) async {
|
||||
final (platform, density, color) = testCase;
|
||||
final ffi = _FFI(platform);
|
||||
final cursor = CursorModel(WeakReference(ffi))..id = '$testCase-$dpr';
|
||||
addTearDown(() {
|
||||
cursor.disposeImages();
|
||||
cursor.dispose();
|
||||
});
|
||||
final palette = [color, ..._colors.skip(1)];
|
||||
final rgba = [
|
||||
for (var y = 0; y < _side; y++)
|
||||
for (var x = 0; x < _side; x++) ...palette[x ~/ _stripeWidth]
|
||||
];
|
||||
await cursor.updateCursorData({
|
||||
'id': '$testCase-$dpr',
|
||||
'width': '$_side',
|
||||
'height': '$_side',
|
||||
'hotx': '${_hotspot.dx}',
|
||||
'hoty': '${_hotspot.dy}',
|
||||
if (density != null) 'scale': density,
|
||||
'colors': jsonEncode(rgba),
|
||||
});
|
||||
final cache = cursor.cache!;
|
||||
buildCursorOfCache(cursor, Platform.isWindows ? dpr : 1, cache);
|
||||
await deleteCustomCursor(cursor.cachedKeys.single);
|
||||
expect(cache.image.getBytes(), rgba); // Keep the original byte-cache source.
|
||||
_checkRegistration(registrations.single, dpr);
|
||||
// The same ui.Image also supplies the painted remote cursor.
|
||||
final straight = await cache.nativeImage
|
||||
.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
|
||||
_checkColors(img.Image.fromBytes(
|
||||
width: _side,
|
||||
height: _side,
|
||||
bytes: straight!.buffer,
|
||||
bytesOffset: straight.offsetInBytes,
|
||||
order: img.ChannelOrder.rgba));
|
||||
// Real sessions own FFI strongly throughout asynchronous cursor decoding.
|
||||
expect(cursor.parent.target, same(ffi));
|
||||
}
|
||||
|
||||
void _checkRegistration(Map<dynamic, dynamic> args, double dpr) {
|
||||
final bytes = args['buffer'] as Uint8List;
|
||||
final decoded = Platform.isWindows
|
||||
? img.Image.fromBytes(
|
||||
width: args['width'] as int,
|
||||
height: args['height'] as int,
|
||||
bytes: bytes.buffer,
|
||||
bytesOffset: bytes.offsetInBytes,
|
||||
order: img.ChannelOrder.bgra)
|
||||
: img.decodePng(bytes)!;
|
||||
expect((decoded.width, decoded.height), (_side * dpr, _side * dpr));
|
||||
expect((args['hotX'], args['hotY']), (_hotspot.dx * dpr, _hotspot.dy * dpr));
|
||||
_checkColors(decoded);
|
||||
}
|
||||
|
||||
void _checkColors(img.Image bitmap) {
|
||||
for (var stripe = 0; stripe < _colors.length; stripe++) {
|
||||
final pixel = bitmap.getPixel(
|
||||
((stripe + 0.5) * bitmap.width / _colors.length).floor(),
|
||||
bitmap.height ~/ 2);
|
||||
final expected = _colors[stripe];
|
||||
expect(pixel.a, expected.last);
|
||||
for (final (actual, wanted) in [
|
||||
(pixel.r, expected[0]),
|
||||
(pixel.g, expected[1]),
|
||||
(pixel.b, expected[2])
|
||||
]) {
|
||||
expect(actual, closeTo(wanted, 1), reason: 'RGBA stripe $stripe');
|
||||
}
|
||||
}
|
||||
}
|
||||
297
flutter/test/cursor_paint_scale_test.dart
Normal file
297
flutter/test/cursor_paint_scale_test.dart
Normal file
@@ -0,0 +1,297 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/utils/image.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const _hotspot = Offset(4, 9);
|
||||
const _remotePosition = Offset(100.25, 80.75);
|
||||
const _canvasOffset = Offset(15.125, -10.25);
|
||||
const _viewport = Size(200, 160);
|
||||
|
||||
class _CursorModel extends ChangeNotifier implements CursorModel {
|
||||
_CursorModel(this.image, this.position, this.density);
|
||||
|
||||
final Offset position;
|
||||
final double density;
|
||||
@override
|
||||
CursorData get cache => _Density(density);
|
||||
|
||||
@override
|
||||
final ui.Image image;
|
||||
@override
|
||||
double get hotx => _hotspot.dx;
|
||||
@override
|
||||
double get hoty => _hotspot.dy;
|
||||
@override
|
||||
double get x => position.dx;
|
||||
@override
|
||||
double get y => position.dy;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Density extends Fake implements CursorData {
|
||||
_Density(this.pixelRatio);
|
||||
@override
|
||||
final double pixelRatio;
|
||||
}
|
||||
|
||||
class _ImageModel extends ChangeNotifier implements ImageModel {
|
||||
_ImageModel(this.useTextureRender);
|
||||
|
||||
@override
|
||||
final bool useTextureRender;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _TextureModel extends Fake implements TextureModel {
|
||||
@override
|
||||
RxInt getTextureId(int display) => 0.obs;
|
||||
}
|
||||
|
||||
class _Display extends Display {
|
||||
_Display(this.scale, double left) {
|
||||
x = left;
|
||||
width = 3840;
|
||||
height = 2160;
|
||||
}
|
||||
|
||||
@override
|
||||
final double scale;
|
||||
}
|
||||
|
||||
class _Peer extends Fake implements FfiModel {
|
||||
@override
|
||||
final pi = PeerInfo()
|
||||
..displays.add(Display()
|
||||
..width = _viewport.width.toInt()
|
||||
..height = _viewport.height.toInt());
|
||||
@override
|
||||
bool isPeerLinux = false;
|
||||
@override
|
||||
Rect rect = Offset.zero & _viewport;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
_FFI(bool useTexture) : imageModel = _ImageModel(useTexture);
|
||||
|
||||
@override
|
||||
final ImageModel imageModel;
|
||||
@override
|
||||
final _Peer ffiModel = _Peer();
|
||||
@override
|
||||
final textureModel = _TextureModel();
|
||||
@override
|
||||
late CanvasModel canvasModel;
|
||||
}
|
||||
|
||||
class _CanvasModel extends ChangeNotifier implements CanvasModel {
|
||||
_CanvasModel(String style, this.scale, bool useTexture)
|
||||
: _ffi = _FFI(useTexture),
|
||||
viewStyle = ViewStyle(
|
||||
style: style,
|
||||
width: _viewport.width,
|
||||
height: _viewport.height,
|
||||
displayWidth: _viewport.width.toInt(),
|
||||
displayHeight: _viewport.height.toInt(),
|
||||
) {
|
||||
_ffi.canvasModel = this;
|
||||
}
|
||||
|
||||
final _FFI _ffi;
|
||||
@override
|
||||
WeakReference<FFI> get parent => WeakReference(_ffi);
|
||||
@override
|
||||
final imageOverflow = false.obs;
|
||||
@override
|
||||
final ViewStyle viewStyle;
|
||||
@override
|
||||
double get x => _canvasOffset.dx;
|
||||
@override
|
||||
double get y => _canvasOffset.dy;
|
||||
@override
|
||||
final double scale;
|
||||
@override
|
||||
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
|
||||
@override
|
||||
Size get size => _viewport;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _ScrollbarCanvasModel extends _CanvasModel {
|
||||
_ScrollbarCanvasModel(String style, {Size? frame, double scale = 2})
|
||||
: super(style, scale, true) {
|
||||
imageOverflow.value = true;
|
||||
if (frame != null) _ffi.ffiModel.rect = Offset.zero & frame;
|
||||
}
|
||||
|
||||
@override
|
||||
ScrollStyle get scrollStyle => ScrollStyle.scrollbar;
|
||||
@override
|
||||
double get scrollX => _ffi.ffiModel.rect.width * scale > size.width ? 0.1 : 0;
|
||||
@override
|
||||
double get scrollY => _ffi.ffiModel.rect.height * scale > size.height ? 0.2 : 0;
|
||||
}
|
||||
|
||||
class _Canvas extends Fake implements Canvas {
|
||||
double factor = 1;
|
||||
Offset? position;
|
||||
|
||||
@override
|
||||
void scale(double sx, [double? sy]) => factor *= sx;
|
||||
|
||||
@override
|
||||
void drawImage(ui.Image image, Offset offset, Paint paint) {
|
||||
position = offset * factor;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImagePainter> _paintCursor(WidgetTester tester, CanvasModel canvas,
|
||||
{double dpr = 2, bool zoom = true, (int, int) source = (48, 64),
|
||||
Offset position = _remotePosition, double density = 0}) async {
|
||||
final image = (await tester
|
||||
.runAsync(() => createTestImage(width: source.$1, height: source.$2)))!;
|
||||
addTearDown(image.dispose);
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
tester.view.physicalSize = _viewport * dpr;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr,
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>.value(value: canvas.parent.target!.imageModel),
|
||||
ChangeNotifierProvider<CursorModel>(create: (_) => _CursorModel(image, position, density)),
|
||||
ChangeNotifierProvider<CanvasModel>(create: (_) => canvas),
|
||||
],
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
// Measure the video's origin instead of copying its rounding policy.
|
||||
if (canvas.parent.target!.imageModel.useTextureRender &&
|
||||
canvas.scrollStyle == ScrollStyle.scrollauto)
|
||||
ImagePaint(ffi: canvas.parent.target!, id: 'cursor-test',
|
||||
zoomCursor: zoom.obs, cursorOverImage: false.obs,
|
||||
keyboardEnabled: true.obs, remoteCursorMoved: false.obs),
|
||||
CursorPaint(id: 'cursor-test', zoomCursor: zoom.obs),
|
||||
]),
|
||||
),
|
||||
)));
|
||||
final painter = tester.widget<CustomPaint>(find.byType(CustomPaint)).painter!
|
||||
as ImagePainter;
|
||||
expect(painter.image, same(image));
|
||||
return painter;
|
||||
}
|
||||
|
||||
void _linuxDisplayTests() {
|
||||
for (final (texture, displayScale, allDisplays) in [
|
||||
(false, 2.0, false), (true, 2.0, false), (true, 2.0, true),
|
||||
(false, 1.0, false), (true, 1.0, false),
|
||||
]) {
|
||||
testWidgets('Linux scale=$displayScale texture=$texture all=$allDisplays',
|
||||
(tester) async {
|
||||
final canvas = _CanvasModel(kRemoteViewStyleCustom, 2, texture);
|
||||
final peer = canvas._ffi.ffiModel;
|
||||
peer.isPeerLinux = true;
|
||||
// The first output's physical extent overlaps the second in logical
|
||||
// coordinates. Selection must use logical extents and the union origin.
|
||||
peer.pi.displays.assignAll([
|
||||
if (displayScale > 1) _Display(4, -960), _Display(displayScale, 0),
|
||||
]);
|
||||
peer.pi.currentDisplay = allDisplays ? kAllDisplayValue
|
||||
: peer.pi.displays.length - 1;
|
||||
peer.rect = Rect.fromLTWH(allDisplays ? -960 : 0, 0, 3840, 2160);
|
||||
final painter = await _paintCursor(tester, canvas,
|
||||
dpr: 1, zoom: false, source: (64, 64), density: 2,
|
||||
position: allDisplays ? _remotePosition + const Offset(960, 0)
|
||||
: _remotePosition);
|
||||
final pixelScale = 2 / displayScale;
|
||||
expect(painter.scale, pixelScale);
|
||||
final origin = texture ? tester.getTopLeft(find.byType(Texture).first)
|
||||
: Offset((_canvasOffset.dx / pixelScale).toInt() * pixelScale,
|
||||
(_canvasOffset.dy / pixelScale).toInt() * pixelScale);
|
||||
final position = allDisplays
|
||||
? _remotePosition + const Offset(960, 0) : _remotePosition;
|
||||
expect((Offset(painter.x, painter.y) + _hotspot) * painter.scale,
|
||||
position * 2 + origin);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
_linuxDisplayTests();
|
||||
final minimumScale = Platform.isWindows ? 1 / 3 : 2 / 3;
|
||||
for (final (style, zoom, dpr, source, canvasScale, scale, texture, density) in [
|
||||
(kRemoteViewStyleAdaptive, false, 2.0, (48, 64), 0.375, 0.375, true, 0.0),
|
||||
(kRemoteViewStyleOriginal, false, 2.0, (48, 64), 0.5, 0.5, true, 0.0),
|
||||
(kRemoteViewStyleCustom, false, 2.0, (48, 64), 0.25, 0.25, false, 0.0),
|
||||
(kRemoteViewStyleCustom, true, 2.0, (48, 64), 2.0, 2.0, false, 0.0),
|
||||
(kRemoteViewStyleAdaptive, false, 2.25, (48, 48), 0.375, 0.375, false, 0.0),
|
||||
(kRemoteViewStyleAdaptive, true, 2.0, (9, 18), 0.1, minimumScale, true, 0.0),
|
||||
(kRemoteViewStyleAdaptive, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||
(kRemoteViewStyleAdaptive, true, 2.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||
(kRemoteViewStyleCustom, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||
(kRemoteViewStyleCustom, true, 2.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||
(kRemoteViewStyleCustom, true, 2.0, (8, 10), 1.0, 1.2, true, 2.0),
|
||||
(kRemoteViewStyleOriginal, true, 1.0, (48, 64), 0.05, 0.25, true, 2.0),
|
||||
(kRemoteViewStyleOriginal, true, 2.0, (48, 64), 0.05,
|
||||
Platform.isWindows ? 0.125 : 0.25, true, 2.0),
|
||||
]) {
|
||||
testWidgets(
|
||||
'$style zoom=$zoom dpr=$dpr source=$source texture=$texture density=$density keeps remote geometry',
|
||||
(tester) async {
|
||||
final painter = await _paintCursor(
|
||||
tester, _CanvasModel(style, canvasScale, texture),
|
||||
dpr: dpr, zoom: zoom, source: source, density: density);
|
||||
expect(painter.scale, scale);
|
||||
var imageOrigin = texture
|
||||
? tester.getTopLeft(find.byType(Texture).first) : _canvasOffset;
|
||||
if (!texture) {
|
||||
final background = _Canvas();
|
||||
ImagePainter(
|
||||
image: painter.image,
|
||||
x: _canvasOffset.dx / canvasScale,
|
||||
y: _canvasOffset.dy / canvasScale,
|
||||
scale: canvasScale,
|
||||
).paint(background, _viewport);
|
||||
imageOrigin = background.position!;
|
||||
}
|
||||
final target = _remotePosition * canvasScale + imageOrigin;
|
||||
final hotspot = (Offset(painter.x, painter.y) + _hotspot) * scale;
|
||||
expect(hotspot.dx, closeTo(target.dx, 1e-9));
|
||||
expect(hotspot.dy, closeTo(target.dy, 1e-9));
|
||||
final canvas = _Canvas();
|
||||
painter.paint(canvas, _viewport);
|
||||
final position = canvas.position! + _hotspot * canvas.factor;
|
||||
expect(position.dx, closeTo(target.dx, 1e-9));
|
||||
expect(position.dy, closeTo(target.dy, 1e-9));
|
||||
});
|
||||
}
|
||||
for (final style in [kRemoteViewStyleOriginal, kRemoteViewStyleCustom]) {
|
||||
for (final (frame, scale, offset) in [
|
||||
(_viewport, 2.0, const Offset(-40, -64)),
|
||||
(const Size(199, 320), 1.0, const Offset(0, -64)),
|
||||
(const Size(198, 320), 1.0, const Offset(1, -64)),
|
||||
(const Size(400, 159), 1.0, const Offset(-40, 0)),
|
||||
(const Size(400, 158), 1.0, const Offset(-40, 1)),
|
||||
]) {
|
||||
testWidgets('$style frame=$frame painted cursor follows scrollbar layout',
|
||||
(tester) async {
|
||||
final painter = await _paintCursor(
|
||||
tester, _ScrollbarCanvasModel(style, frame: frame, scale: scale));
|
||||
final target = _remotePosition * scale + offset;
|
||||
expect((Offset(painter.x, painter.y) + _hotspot) * painter.scale, target);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
299
flutter/test/cursor_scroll_edge_test.dart
Normal file
299
flutter/test/cursor_scroll_edge_test.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/utils/image.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:vector_math/vector_math.dart' show Vector2;
|
||||
|
||||
const _viewport = Size(200, 160);
|
||||
const _hotspot = Offset(4, 9);
|
||||
const _scrollDeltas = [80.0, -20.0, 1000.0, 1000.0, -1000.0, -1000.0, 80.0, 0.0];
|
||||
|
||||
class _Peer extends Fake implements FfiModel {
|
||||
_Peer(ui.Image frame)
|
||||
: rect = Rect.fromLTWH(
|
||||
0, 0, frame.width.toDouble(), frame.height.toDouble()) {
|
||||
pi.displays.add(Display()
|
||||
..width = frame.width
|
||||
..height = frame.height);
|
||||
}
|
||||
@override
|
||||
final pi = PeerInfo();
|
||||
@override
|
||||
final Rect rect;
|
||||
@override
|
||||
bool get isPeerLinux => false;
|
||||
}
|
||||
|
||||
class _Image extends ImageModel {
|
||||
_Image(FFI ffi, this.image, this.useTextureRender)
|
||||
: super(WeakReference(ffi));
|
||||
@override
|
||||
final ui.Image image;
|
||||
@override
|
||||
final bool useTextureRender;
|
||||
}
|
||||
|
||||
class _Texture extends Fake implements TextureModel {
|
||||
@override
|
||||
RxInt getTextureId(int display) => 0.obs;
|
||||
}
|
||||
|
||||
class _ScrollCanvas extends CanvasModel {
|
||||
_ScrollCanvas(FFI ffi, String style, this.scrollStyle)
|
||||
: viewStyle = ViewStyle(
|
||||
style: style,
|
||||
width: _viewport.width,
|
||||
height: _viewport.height,
|
||||
displayWidth: ffi.ffiModel.rect!.width.toInt(),
|
||||
displayHeight: ffi.ffiModel.rect!.height.toInt()),
|
||||
super(WeakReference(ffi)) {
|
||||
resetOffset();
|
||||
imageOverflow.value = true;
|
||||
}
|
||||
Size viewport = _viewport;
|
||||
double imageScale = 1;
|
||||
@override
|
||||
Size get size => viewport;
|
||||
@override
|
||||
double get scale => imageScale;
|
||||
@override
|
||||
double get x => (size.width - getDisplayWidth() * scale) / 2;
|
||||
@override
|
||||
double get y => (size.height - getDisplayHeight() * scale) / 2;
|
||||
@override
|
||||
final ScrollStyle scrollStyle;
|
||||
@override
|
||||
final ViewStyle viewStyle;
|
||||
|
||||
void relayout(Size viewport, double dpr) {
|
||||
this.viewport = viewport;
|
||||
imageScale = 1 / dpr;
|
||||
imageOverflow.value = viewport.width < getDisplayWidth() * scale ||
|
||||
viewport.height < getDisplayHeight() * scale;
|
||||
setScrollPercent(0, 0);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class _Cursor extends CursorModel {
|
||||
_Cursor(FFI ffi, this.image, this.position) : super(WeakReference(ffi));
|
||||
final Offset position;
|
||||
@override
|
||||
final ui.Image image;
|
||||
@override
|
||||
double get hotx => _hotspot.dx;
|
||||
@override
|
||||
double get hoty => _hotspot.dy;
|
||||
@override
|
||||
double get x => position.dx;
|
||||
@override
|
||||
double get y => position.dy;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
_FFI(ui.Image frame, ui.Image cursor,
|
||||
{required bool texture, required (String, ScrollStyle) style, required Offset position})
|
||||
: ffiModel = _Peer(frame) {
|
||||
imageModel = _Image(this, frame, texture);
|
||||
canvasModel = _ScrollCanvas(this, style.$1, style.$2);
|
||||
cursorModel = _Cursor(this, cursor, position);
|
||||
}
|
||||
@override
|
||||
final sessionId = Uuid().v4obj();
|
||||
@override
|
||||
final _Peer ffiModel;
|
||||
@override
|
||||
late final ImageModel imageModel;
|
||||
@override
|
||||
late final CanvasModel canvasModel;
|
||||
@override
|
||||
late final CursorModel cursorModel;
|
||||
@override
|
||||
final textureModel = _Texture();
|
||||
}
|
||||
|
||||
class _Draw extends Fake implements Canvas {
|
||||
double factor = 1;
|
||||
Offset? position;
|
||||
@override
|
||||
void scale(double sx, [double? sy]) => factor *= sx;
|
||||
@override
|
||||
void drawImage(ui.Image image, Offset offset, Paint paint) =>
|
||||
position = offset * factor;
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final style in [(kRemoteViewStyleOriginal, ScrollStyle.scrolledge),
|
||||
(kRemoteViewStyleCustom, ScrollStyle.scrolledge),
|
||||
(kRemoteViewStyleCustom, ScrollStyle.scrollbar)]) {
|
||||
for (final texture in [false, true]) {
|
||||
for (final (frame, dpr) in [
|
||||
(const Size(199, 320), 1.0),
|
||||
(const Size(198, 320), 2.0),
|
||||
(const Size(400, 159), 2.0),
|
||||
(const Size(400, 158), 1.0),
|
||||
]) {
|
||||
testWidgets(
|
||||
'Scroll $style texture=$texture frame=$frame DPR=$dpr',
|
||||
(tester) => tester
|
||||
.runAsync(() => _check(tester, (style, texture, frame, dpr))));
|
||||
}
|
||||
for (final refreshBeforeLayout in [true, false]) {
|
||||
testWidgets(
|
||||
'Scroll relayout $style texture=$texture early=$refreshBeforeLayout',
|
||||
(tester) => tester.runAsync(() =>
|
||||
_checkRelayout(tester, (style, texture, refreshBeforeLayout))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _check(
|
||||
WidgetTester tester, ((String, ScrollStyle), bool, Size, double) testCase) async {
|
||||
final (style, texture, frame, dpr) = testCase;
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
tester.view.physicalSize = _viewport * dpr;
|
||||
addTearDown(tester.view.reset);
|
||||
final vertical = frame.height > _viewport.height;
|
||||
final pointer =
|
||||
vertical ? const Offset(50.25, 200.75) : const Offset(240.25, 50.75);
|
||||
final video = await createTestImage(
|
||||
width: frame.width.toInt(), height: frame.height.toInt());
|
||||
final cursor = await createTestImage(width: 48, height: 64);
|
||||
final ffi =
|
||||
_FFI(video, cursor, texture: texture, style: style, position: pointer);
|
||||
addTearDown(() {
|
||||
ffi.canvasModel.scrollHorizontal.dispose();
|
||||
ffi.canvasModel.scrollVertical.dispose();
|
||||
ffi.canvasModel.dispose();
|
||||
ffi.imageModel.dispose();
|
||||
ffi.cursorModel.dispose();
|
||||
video.dispose();
|
||||
cursor.dispose();
|
||||
});
|
||||
await _mount(tester, ffi, dpr);
|
||||
final videoWidget = texture ? find.byType(Texture) : _paintOf(video);
|
||||
final scrolling = vertical
|
||||
? ffi.canvasModel.scrollVertical
|
||||
: ffi.canvasModel.scrollHorizontal;
|
||||
for (final distance in _scrollDeltas) {
|
||||
// Drive the real scroll controllers; canvas pan offsets are not the video origin.
|
||||
ffi.canvasModel.performEdgeScroll(
|
||||
vertical ? Vector2(0, distance) : Vector2(distance, 0));
|
||||
expect(vertical ? ffi.canvasModel.scrollY : ffi.canvasModel.scrollX,
|
||||
closeTo(scrolling.offset / (vertical ? frame.height : frame.width), 1e-9));
|
||||
await tester.pump();
|
||||
_expectAlignment(tester, ffi, videoWidget);
|
||||
}
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
}
|
||||
|
||||
Future<void> _checkRelayout(
|
||||
WidgetTester tester, ((String, ScrollStyle), bool, bool) testCase) async {
|
||||
final (style, texture, refreshBeforeLayout) = testCase;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = _viewport;
|
||||
addTearDown(tester.view.reset);
|
||||
final video = await createTestImage(width: 400, height: 320);
|
||||
final cursor = await createTestImage(width: 48, height: 64);
|
||||
final ffi = _FFI(video, cursor,
|
||||
texture: texture, style: style, position: const Offset(150.25, 120.75));
|
||||
final canvas = ffi.canvasModel as _ScrollCanvas;
|
||||
addTearDown(() {
|
||||
canvas.scrollHorizontal.dispose();
|
||||
canvas.scrollVertical.dispose();
|
||||
canvas.dispose();
|
||||
ffi.imageModel.dispose();
|
||||
ffi.cursorModel.dispose();
|
||||
video.dispose();
|
||||
cursor.dispose();
|
||||
});
|
||||
await _mount(tester, ffi, 1);
|
||||
canvas.performEdgeScroll(Vector2(20, 20));
|
||||
await tester.pump();
|
||||
final videoWidget = texture ? find.byType(Texture) : _paintOf(video);
|
||||
_expectAlignment(tester, ffi, videoWidget);
|
||||
for (final (viewport, dpr) in [
|
||||
(const Size(160, 120), 2.0),
|
||||
(const Size(195, 155), 2.0), // Clamp both existing scroll positions.
|
||||
(const Size(240, 155), 2.0), // Detach the horizontal scroll controller.
|
||||
(const Size(240, 200), 2.0), // No scrolling remains.
|
||||
(_viewport, 1.0),
|
||||
]) {
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
tester.view.physicalSize = viewport * dpr;
|
||||
canvas.relayout(viewport, dpr);
|
||||
// A settings refresh may run before layout or after the first cursor build.
|
||||
if (refreshBeforeLayout) {
|
||||
canvas.updateScrollPercent();
|
||||
} else {
|
||||
tester.binding.addPostFrameCallback((_) => canvas.updateScrollPercent());
|
||||
}
|
||||
await _mount(tester, ffi, dpr);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 16),
|
||||
EnginePhase.sendSemanticsUpdate, const Duration(seconds: 1));
|
||||
_expectAlignment(tester, ffi, videoWidget);
|
||||
}
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
}
|
||||
|
||||
void _expectAlignment(WidgetTester tester, FFI ffi, Finder videoWidget) {
|
||||
final cursor = ffi.cursorModel;
|
||||
final cursorWidget = _paintOf(cursor.image!);
|
||||
final painter =
|
||||
tester.widget<CustomPaint>(cursorWidget).painter! as ImagePainter;
|
||||
final output = _Draw();
|
||||
painter.paint(output, ffi.canvasModel.size);
|
||||
final hotspot = tester.getTopLeft(cursorWidget) +
|
||||
output.position! +
|
||||
_hotspot * output.factor;
|
||||
var videoOrigin = tester.getTopLeft(videoWidget);
|
||||
final video = tester.widget(videoWidget);
|
||||
if (video is CustomPaint) {
|
||||
final drawnVideo = _Draw();
|
||||
video.painter!.paint(drawnVideo, ffi.canvasModel.size);
|
||||
videoOrigin += drawnVideo.position!;
|
||||
}
|
||||
final target =
|
||||
videoOrigin + Offset(cursor.x, cursor.y) * ffi.canvasModel.scale;
|
||||
expect(hotspot.dx, closeTo(target.dx, 1e-9));
|
||||
expect(hotspot.dy, closeTo(target.dy, 1e-9));
|
||||
}
|
||||
|
||||
Finder _paintOf(ui.Image image) => find.byWidgetPredicate((widget) =>
|
||||
widget is CustomPaint &&
|
||||
widget.painter is ImagePainter &&
|
||||
identical((widget.painter as ImagePainter).image, image));
|
||||
|
||||
Future<void> _mount(WidgetTester tester, FFI ffi, double dpr) =>
|
||||
tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>.value(
|
||||
value: ffi.imageModel),
|
||||
ChangeNotifierProvider<CanvasModel>.value(
|
||||
value: ffi.canvasModel),
|
||||
ChangeNotifierProvider<CursorModel>.value(
|
||||
value: ffi.cursorModel),
|
||||
],
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
ImagePaint(
|
||||
ffi: ffi,
|
||||
id: 'scroll-edge-test',
|
||||
zoomCursor: false.obs,
|
||||
cursorOverImage: false.obs,
|
||||
keyboardEnabled: true.obs,
|
||||
remoteCursorMoved: false.obs),
|
||||
CursorPaint(id: 'scroll-edge-test', zoomCursor: false.obs),
|
||||
])))));
|
||||
290
flutter/test/cursor_web_test.dart
Normal file
290
flutter/test/cursor_web_test.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
// The test drives the cursor lifecycle normally owned by MouseTracker.
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
@TestOn('browser')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:js' as js;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart' as model;
|
||||
import 'package:flutter_hbb/web/custom_cursor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
_alphaTests();
|
||||
_thinCursorTests();
|
||||
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||
for (final dpr in [1.0, 2.0]) {
|
||||
for (final (platform, density) in [
|
||||
(kPeerPlatformMacOS, null), (kPeerPlatformMacOS, 0.0),
|
||||
(kPeerPlatformMacOS, 1.0), (kPeerPlatformMacOS, 2.0),
|
||||
(kPeerPlatformLinux, 2.0), (kPeerPlatformWindows, 2.0),
|
||||
]) {
|
||||
testWidgets('Web $style zoom off DPR $dpr $platform density=$density',
|
||||
(tester) => tester.runAsync(() => _checkPolicy(tester, style, dpr,
|
||||
pixelRatio: density, platform: platform)));
|
||||
}
|
||||
}
|
||||
}
|
||||
test('Web cursor aligns CSS hotspots with rounded PNG dimensions', () async {
|
||||
final registered = _captureCursor();
|
||||
final canvas = _Canvas(kRemoteViewStyleAdaptive);
|
||||
addTearDown(canvas.dispose);
|
||||
final ffi = _FFI(canvas);
|
||||
for (final (hotspot, scale, side, expected) in [
|
||||
((7.0, 7.0), 633 / 1600, 19, (3, 3)),
|
||||
((21.0, 23.0), 633 / 1600, 19, (8, 9)),
|
||||
((22.0, 22.0), 633 / 1600, 19, (9, 9)),
|
||||
((21.0, 23.0), 0.05, 12, (5, 6)),
|
||||
((21.0, 23.0), 0.5, 24, (11, 12)),
|
||||
((7.0, 7.0), 1.0, 48, (7, 7)),
|
||||
]) {
|
||||
final cursor = await _loadCursor(ffi, '$hotspot-$scale',
|
||||
hotspot: hotspot, pixelRatio: null);
|
||||
final session =
|
||||
buildCursorOfCache(cursor, scale, cursor.cache).createSession(1);
|
||||
await session.activate();
|
||||
final uri = Uri.parse(registered['url'] as String);
|
||||
final bitmap = img.decodePng(uri.data!.contentAsBytes())!;
|
||||
expect((bitmap.width, bitmap.height), (side, side));
|
||||
expect((registered['hotx'], registered['hoty']), expected);
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _thinCursorTests() {
|
||||
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||
for (final zoom in [false, true]) {
|
||||
testWidgets('ImagePaint Web $style thin cursor zoom=$zoom', (tester) =>
|
||||
tester.runAsync(() => _checkPolicy(tester, style, 1,
|
||||
zoom: zoom, source: (64, 4), hotspot: (32, 2),
|
||||
expectedSize: zoom ? (32, 2) : (64, 4),
|
||||
expectedHotspot: zoom ? (16, 1) : (32, 2))));
|
||||
}
|
||||
}
|
||||
test('Web thin cursors keep a raster pixel and an in-bounds hotspot', () async {
|
||||
final registered = _captureCursor();
|
||||
final canvas = _Canvas(kRemoteViewStyleAdaptive);
|
||||
addTearDown(canvas.dispose);
|
||||
final ffi = _FFI(canvas);
|
||||
for (final (source, hotspot, scale, size, expected) in [
|
||||
((4, 64), (2.0, 32.0), 0.5, (2, 32), (1, 16)),
|
||||
((2, 128), (1.0, 64.0), 0.01, (1, 12), (0, 6)),
|
||||
((128, 2), (64.0, 1.0), 0.01, (12, 1), (6, 0)),
|
||||
]) {
|
||||
final cursor = await _loadCursor(ffi, '$source',
|
||||
source: source, hotspot: hotspot);
|
||||
for (final factor in [1.0, scale, 1.0]) {
|
||||
final session =
|
||||
buildCursorOfCache(cursor, factor, cursor.cache).createSession(1);
|
||||
await session.activate();
|
||||
session.dispose();
|
||||
final png = img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
|
||||
expect((png.width, png.height), factor == 1 ? source : size);
|
||||
expect((registered['hotx'], registered['hoty']),
|
||||
factor == 1 ? (hotspot.$1, hotspot.$2) : expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class _Image extends ChangeNotifier implements model.ImageModel {
|
||||
@override
|
||||
bool get useTextureRender => false;
|
||||
@override
|
||||
ui.Image? get image => null;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Canvas extends ChangeNotifier implements model.CanvasModel {
|
||||
_Canvas(String style)
|
||||
: viewStyle = model.ViewStyle(
|
||||
style: style,
|
||||
width: 200,
|
||||
height: 160,
|
||||
displayWidth: 400,
|
||||
displayHeight: 320);
|
||||
@override
|
||||
final model.ViewStyle viewStyle;
|
||||
@override
|
||||
final imageOverflow = false.obs;
|
||||
@override
|
||||
bool get cursorEmbedded => false;
|
||||
@override
|
||||
Size get size => const Size(200, 160);
|
||||
@override
|
||||
double get scale => 0.5;
|
||||
@override
|
||||
double get x => 0;
|
||||
@override
|
||||
double get y => 0;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Input extends Fake implements InputModel {
|
||||
@override
|
||||
final relativeMouseMode = false.obs;
|
||||
}
|
||||
|
||||
class _Peer extends Fake implements model.FfiModel {
|
||||
@override
|
||||
final pi = model.PeerInfo();
|
||||
@override
|
||||
bool get isPeerLinux => false;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements model.FFI {
|
||||
_FFI(this.canvasModel);
|
||||
@override
|
||||
final model.CanvasModel canvasModel;
|
||||
@override
|
||||
final inputModel = _Input();
|
||||
@override
|
||||
final ffiModel = _Peer();
|
||||
}
|
||||
|
||||
Future<model.CursorModel> _loadCursor(model.FFI ffi, String id,
|
||||
{List<int> pixel = const [255, 255, 255, 255],
|
||||
(int, int) source = (48, 48),
|
||||
(double, double) hotspot = (7, 9),
|
||||
double? pixelRatio = 1}) async {
|
||||
final cursor = model.CursorModel(WeakReference(ffi))..id = id;
|
||||
await cursor.updateCursorData({
|
||||
'id': id,
|
||||
'width': '${source.$1}',
|
||||
'height': '${source.$2}',
|
||||
'hotx': '${hotspot.$1}',
|
||||
'hoty': '${hotspot.$2}',
|
||||
if (pixelRatio != null) 'scale': '$pixelRatio',
|
||||
'colors': jsonEncode([for (var i = 0; i < source.$1 * source.$2; i++) ...pixel]),
|
||||
});
|
||||
addTearDown(() async {
|
||||
// Keep the session owner alive across asynchronous image decoding.
|
||||
expect(cursor.parent.target, same(ffi));
|
||||
for (final key in cursor.cachedKeys) {
|
||||
await deleteCustomCursor(key);
|
||||
}
|
||||
cursor.disposeImages();
|
||||
cursor.dispose();
|
||||
});
|
||||
return cursor;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _captureCursor() {
|
||||
final originals = {
|
||||
for (final key in ['isMobile', 'getByName', 'setByName'])
|
||||
key: js.context[key]
|
||||
};
|
||||
js.context['isMobile'] = js.allowInterop(() => false);
|
||||
js.context['getByName'] = js.allowInterop((String name, String value) => '');
|
||||
final registered = <String, dynamic>{};
|
||||
js.context['setByName'] = js.allowInterop((String name, String value) {
|
||||
if (name == 'cursor') {
|
||||
registered
|
||||
..clear()
|
||||
..addAll(jsonDecode(value));
|
||||
}
|
||||
});
|
||||
addTearDown(() {
|
||||
for (final entry in originals.entries) {
|
||||
js.context[entry.key] = entry.value;
|
||||
}
|
||||
});
|
||||
return registered;
|
||||
}
|
||||
|
||||
Future<void> _checkPolicy(WidgetTester tester, String style, double dpr,
|
||||
{bool zoom = false, (int, int) source = (48, 48),
|
||||
(double, double) hotspot = (7, 9), (int, int) expectedSize = (48, 48),
|
||||
(int, int) expectedHotspot = (7, 9), double? pixelRatio = 1,
|
||||
String platform = kPeerPlatformMacOS}) async {
|
||||
final registered = _captureCursor();
|
||||
final canvas = _Canvas(style);
|
||||
addTearDown(canvas.dispose);
|
||||
final ffi = _FFI(canvas);
|
||||
ffi.ffiModel.pi.platform = platform;
|
||||
// Retina export changes the bitmap too; varying metadata alone misses this boundary.
|
||||
final density = platform == kPeerPlatformMacOS && pixelRatio == 2 ? 2 : 1;
|
||||
final cursor = await _loadCursor(ffi, '$style-$dpr-$zoom-$source-$platform-$pixelRatio',
|
||||
source: (source.$1 * density, source.$2 * density), pixelRatio: pixelRatio,
|
||||
hotspot: (hotspot.$1 * density, hotspot.$2 * density));
|
||||
await tester.pumpWidget(MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<model.ImageModel>(create: (_) => _Image()),
|
||||
ChangeNotifierProvider<model.CanvasModel>.value(value: canvas),
|
||||
ChangeNotifierProvider<model.CursorModel>.value(value: cursor),
|
||||
],
|
||||
child: ImagePaint(
|
||||
ffi: ffi,
|
||||
id: 'web-cursor-test',
|
||||
zoomCursor: zoom.obs,
|
||||
cursorOverImage: true.obs,
|
||||
keyboardEnabled: true.obs,
|
||||
remoteCursorMoved: false.obs)),
|
||||
));
|
||||
final session = tester
|
||||
.widget<MouseRegion>(find.byType(MouseRegion))
|
||||
.cursor
|
||||
.createSession(1);
|
||||
await session.activate();
|
||||
session.dispose();
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
final png =
|
||||
img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
|
||||
expect((png.width, png.height), expectedSize);
|
||||
expect((registered['hotx'], registered['hoty']), expectedHotspot);
|
||||
}
|
||||
|
||||
void _alphaTests() {
|
||||
for (final (density, pixel) in [
|
||||
(1.0, [255, 255, 255, 128]),
|
||||
(2.0, [255, 128, 64, 128]),
|
||||
(1.0, [255, 128, 64, 112]),
|
||||
(1.0, [0, 0, 0, 0]),
|
||||
(1.0, [255, 255, 255, 255]),
|
||||
// Old macOS hosts send straight alpha without density metadata.
|
||||
(0.0, [255, 128, 64, 128]),
|
||||
(null, [80, 40, 20, 128]),
|
||||
]) {
|
||||
test('Web macOS cursor density $density preserves RGBA $pixel',
|
||||
() => _checkAlpha(density, pixel));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkAlpha(double? density, List<int> pixel) async {
|
||||
final registered = _captureCursor();
|
||||
final canvas = _Canvas(kRemoteViewStyleAdaptive);
|
||||
addTearDown(canvas.dispose);
|
||||
final ffi = _FFI(canvas)..ffiModel.pi.platform = kPeerPlatformMacOS;
|
||||
final cursor = await _loadCursor(ffi, 'alpha-$density-$pixel',
|
||||
pixel: pixel, pixelRatio: density);
|
||||
// Cover both the painted remote cursor and the initial CSS cursor PNG.
|
||||
final straight = await cursor.image!
|
||||
.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
|
||||
expect(straight!.buffer.asUint8List(0, 4), pixel);
|
||||
for (final scale in [1.0, 0.5, 1.0]) {
|
||||
final session =
|
||||
buildCursorOfCache(cursor, scale, cursor.cache).createSession(1);
|
||||
await session.activate();
|
||||
session.dispose();
|
||||
final png =
|
||||
img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
|
||||
final color = png.getPixel(png.width ~/ 2, png.height ~/ 2);
|
||||
expect([color.r, color.g, color.b, color.a], pixel);
|
||||
}
|
||||
}
|
||||
@@ -310,12 +310,16 @@ message KeyEvent {
|
||||
}
|
||||
|
||||
message CursorData {
|
||||
uint64 id = 1;
|
||||
uint64 id = 1 [jstype = JS_STRING];
|
||||
sint32 hotx = 2;
|
||||
sint32 hoty = 3;
|
||||
int32 width = 4;
|
||||
int32 height = 5;
|
||||
bytes colors = 6;
|
||||
// Physical bitmap pixels per remote logical pixel; 0 means unknown density.
|
||||
double scale = 7;
|
||||
// Optional physical image. Legacy receivers keep using the logical-sized fields above.
|
||||
CursorData high_resolution = 8;
|
||||
}
|
||||
|
||||
message CursorPosition {
|
||||
@@ -999,7 +1003,7 @@ message Message {
|
||||
AudioFrame audio_frame = 11;
|
||||
CursorData cursor_data = 12;
|
||||
CursorPosition cursor_position = 13;
|
||||
uint64 cursor_id = 14;
|
||||
uint64 cursor_id = 14 [jstype = JS_STRING];
|
||||
KeyEvent key_event = 15;
|
||||
Clipboard clipboard = 16;
|
||||
FileAction file_action = 17;
|
||||
|
||||
@@ -248,6 +248,25 @@ pub fn wayland_failure_stamped() -> bool {
|
||||
LAST_FAILED_LOOKUP.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
pub enum CachedDisplays {
|
||||
Busy,
|
||||
Ready(Option<Arc<Displays>>),
|
||||
}
|
||||
|
||||
/// Cursor polls must neither wait for discovery nor mistake contention for missing metadata.
|
||||
#[cfg(feature = "drm")]
|
||||
pub fn get_cached_displays() -> CachedDisplays {
|
||||
match DISPLAYS.try_lock() {
|
||||
Ok(cache) => CachedDisplays::Ready(cache.clone()),
|
||||
Err(std::sync::TryLockError::WouldBlock) => CachedDisplays::Busy,
|
||||
Err(err) => {
|
||||
warn!("Failed to read cached Wayland displays: {}", err);
|
||||
CachedDisplays::Ready(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_displays() -> Arc<Displays> {
|
||||
let mut lock = DISPLAYS.lock().unwrap();
|
||||
match lock.as_ref() {
|
||||
@@ -516,6 +535,27 @@ fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_e
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
#[test]
|
||||
fn cursor_metadata_does_not_wait_for_display_discovery() {
|
||||
let cache = DISPLAYS.lock().unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
tx.send(matches!(get_cached_displays(), CachedDisplays::Busy))
|
||||
.unwrap();
|
||||
});
|
||||
// Discovery owns this lock until it completes or times out.
|
||||
let result = rx.recv_timeout(Duration::from_secs(1));
|
||||
drop(cache);
|
||||
reader.join().unwrap();
|
||||
assert!(result.unwrap());
|
||||
|
||||
clear_wayland_displays_cache();
|
||||
for _ in 0..100 {
|
||||
assert!(matches!(get_cached_displays(), CachedDisplays::Ready(None)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_backoff_boundaries() {
|
||||
// Future `now`s sidestep Instant subtraction, which can panic near boot.
|
||||
|
||||
@@ -642,6 +642,7 @@ impl FlutterHandler {
|
||||
|
||||
impl InvokeUiSession for FlutterHandler {
|
||||
fn set_cursor_data(&self, cd: CursorData) {
|
||||
let cd = cd.high_resolution.as_ref().unwrap_or(&cd);
|
||||
let colors = hbb_common::compress::decompress(&cd.colors);
|
||||
self.push_event(
|
||||
"cursor_data",
|
||||
@@ -651,6 +652,7 @@ impl InvokeUiSession for FlutterHandler {
|
||||
("hoty", &cd.hoty.to_string()),
|
||||
("width", &cd.width.to_string()),
|
||||
("height", &cd.height.to_string()),
|
||||
("scale", &cd.scale.to_string()),
|
||||
(
|
||||
"colors",
|
||||
&serde_json::ser::to_string(&colors).unwrap_or("".to_owned()),
|
||||
|
||||
@@ -35,6 +35,9 @@ use std::{
|
||||
use terminfo::{capability as cap, Database};
|
||||
use wallpaper;
|
||||
|
||||
// Cursor density is capture metadata, independent of either endpoint's UI.
|
||||
mod cursor;
|
||||
|
||||
pub const PA_SAMPLE_RATE: u32 = 48000;
|
||||
static mut UNMODIFIED: bool = true;
|
||||
|
||||
@@ -570,7 +573,9 @@ pub fn get_cursor() -> ResultType<Option<u64>> {
|
||||
// polled there is a live session, which is the case the latch reads correctly.
|
||||
#[cfg(feature = "drm")]
|
||||
if !is_x11() {
|
||||
if let Some(id) = crate::server::drm_capturer::drm_cursor_id() {
|
||||
let cursor = cursor::drm_snapshot(|c| c.id)?
|
||||
.map(|(id, scale)| cursor::cache_id(id, scale));
|
||||
if let Some(id) = cursor {
|
||||
// In a mixed DRM + PipeWire session the DRM streams only cover the DRM-backed displays;
|
||||
// when the pointer sits on a PipeWire-served display every DRM stream reports the hidden
|
||||
// sentinel. Returning that sentinel here would hide the cursor globally, including on the
|
||||
@@ -598,6 +603,7 @@ pub fn get_cursor() -> ResultType<Option<u64>> {
|
||||
}
|
||||
}
|
||||
});
|
||||
let res = res.map(cursor::x11_cursor_id);
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -610,7 +616,8 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||
// agree anyway, since a caller that took the DRM branch there has to take it here.
|
||||
#[cfg(feature = "drm")]
|
||||
if !is_x11() {
|
||||
if let Some(c) = crate::server::drm_capturer::drm_cursor() {
|
||||
let cursor = cursor::drm_snapshot(Clone::clone)?;
|
||||
if let Some((c, scale)) = cursor {
|
||||
// See get_cursor(): a hidden DRM sentinel is authoritative only in a pure-DRM session. In
|
||||
// a mixed DRM + PipeWire session fall through so the PipeWire display's cursor is served
|
||||
// by the normal path instead of being hidden everywhere.
|
||||
@@ -618,7 +625,8 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||
|| !crate::server::display_service::has_non_drm_backed_display()
|
||||
{
|
||||
let mut cd: CursorData = Default::default();
|
||||
cd.id = c.id;
|
||||
cd.id = cursor::cache_id(c.id, scale);
|
||||
cd.scale = scale;
|
||||
cd.width = c.width;
|
||||
cd.height = c.height;
|
||||
cd.hotx = c.hotx;
|
||||
@@ -628,20 +636,23 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let scale = cursor::x11_cursor_scale();
|
||||
let matches = |id| cursor::cache_id(id, scale) == hcursor;
|
||||
let mut res = None;
|
||||
DISPLAY.with(|conn| {
|
||||
if let Ok(ref mut d) = conn.try_borrow_mut() {
|
||||
if !d.is_null() {
|
||||
unsafe {
|
||||
let img = XFixesGetCursorImage(**d);
|
||||
if !img.is_null() && hcursor == (*img).cursor_serial as u64 {
|
||||
if !img.is_null() && matches((*img).cursor_serial as u64) {
|
||||
let mut cd: CursorData = Default::default();
|
||||
cd.hotx = (*img).xhot as _;
|
||||
cd.hoty = (*img).yhot as _;
|
||||
cd.width = (*img).width as _;
|
||||
cd.height = (*img).height as _;
|
||||
// to-do: how about if it is 0
|
||||
cd.id = (*img).cursor_serial as _;
|
||||
cd.id = hcursor;
|
||||
cd.scale = scale;
|
||||
let pixels =
|
||||
std::slice::from_raw_parts((*img).pixels, (cd.width * cd.height) as _);
|
||||
// cd.colors.resize(pixels.len() * 4, 0);
|
||||
|
||||
165
src/platform/linux/cursor.rs
Normal file
165
src/platform/linux/cursor.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use hbb_common::{anyhow::Context, bail, log, ResultType};
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::hash_map::DefaultHasher,
|
||||
hash::{Hash, Hasher},
|
||||
};
|
||||
use x11rb::{protocol::xproto::ConnectionExt, rust_connection::RustConnection, NONE};
|
||||
|
||||
mod xsettings;
|
||||
|
||||
#[cfg(test)]
|
||||
mod x11_tests;
|
||||
|
||||
thread_local! {
|
||||
static SETTINGS: RefCell<Option<(RustConnection, usize)>> = const { RefCell::new(None) };
|
||||
static X11_SCALE: Cell<Option<f64>> = const { Cell::new(Some(0.0)) };
|
||||
}
|
||||
|
||||
pub(super) fn cache_id(id: u64, scale: f64) -> u64 {
|
||||
// Legacy Web decoders require JS-safe integers; zero is the service's initial ID.
|
||||
const MAX_CURSOR_ID: u64 = (1 << 53) - 1;
|
||||
if scale == 0.0 {
|
||||
return id;
|
||||
}
|
||||
let mut hash = DefaultHasher::new();
|
||||
(id, scale.to_bits()).hash(&mut hash);
|
||||
hash.finish() % MAX_CURSOR_ID + 1
|
||||
}
|
||||
|
||||
pub(super) fn x11_cursor_id(id: u64) -> u64 {
|
||||
let scale = X11_SCALE.with(|last| match x11_scale() {
|
||||
Ok(scale) => {
|
||||
last.set(Some(scale));
|
||||
scale
|
||||
}
|
||||
Err(err) => {
|
||||
// XSETTINGS is optional; warn once per failure streak without
|
||||
// turning valid XFixes cursor updates into service errors.
|
||||
if last.replace(None).is_some() {
|
||||
log::warn!("Failed to read XSETTINGS cursor density; using unknown density: {err}");
|
||||
}
|
||||
0.0
|
||||
}
|
||||
});
|
||||
cache_id(id, scale)
|
||||
}
|
||||
|
||||
pub(super) fn x11_cursor_scale() -> f64 {
|
||||
// The cursor service reads the ID and bitmap on the same thread. Reuse
|
||||
// that poll's density even if the settings manager changes between them.
|
||||
X11_SCALE.with(|last| last.get().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
pub(super) fn x11_scale() -> ResultType<f64> {
|
||||
if !super::is_x11() {
|
||||
return Ok(0.0);
|
||||
}
|
||||
SETTINGS.with(|settings| {
|
||||
let mut state = settings.try_borrow_mut()?;
|
||||
if state.is_none() {
|
||||
*state = Some(x11rb::connect(None)?);
|
||||
}
|
||||
let (connection, screen) = state.as_ref().context("Missing XSETTINGS connection")?;
|
||||
let result = read_settings(connection, *screen);
|
||||
if result.is_err() {
|
||||
*state = None;
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
fn read_settings(connection: &RustConnection, screen: usize) -> ResultType<f64> {
|
||||
let selection = connection
|
||||
.intern_atom(true, format!("_XSETTINGS_S{screen}").as_bytes())?
|
||||
.reply()?
|
||||
.atom;
|
||||
if selection == NONE {
|
||||
return Ok(0.0);
|
||||
}
|
||||
let owner = connection.get_selection_owner(selection)?.reply()?.owner;
|
||||
if owner == NONE {
|
||||
return Ok(0.0);
|
||||
}
|
||||
let property = connection
|
||||
.intern_atom(true, b"_XSETTINGS_SETTINGS")?
|
||||
.reply()?
|
||||
.atom;
|
||||
let reply = connection
|
||||
.get_property(false, owner, property, property, 0, u32::MAX)?
|
||||
.reply()?;
|
||||
if reply.format != 8 || reply.bytes_after != 0 {
|
||||
bail!("Incomplete XSETTINGS property");
|
||||
}
|
||||
// Xft/DPI includes text scaling; it is not the cursor's pixel density.
|
||||
// Zero explicitly keeps the existing policy on desktops without a window scale.
|
||||
Ok(xsettings::scale(&reply.value)?.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
pub(super) fn drm_snapshot<T>(
|
||||
f: impl Fn(&crate::server::drm_capturer::DrmCursorData) -> T,
|
||||
) -> ResultType<Option<(T, f64)>> {
|
||||
crate::server::drm_capturer::drm_cursor_snapshot(f)
|
||||
.map(|(cursor, display)| {
|
||||
// A hidden cursor or an unavailable display probe has no density metadata.
|
||||
let scale = display
|
||||
.as_ref()
|
||||
.map(wayland_scale)
|
||||
.transpose()?
|
||||
.unwrap_or(0.0);
|
||||
Ok((cursor, scale))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
fn wayland_scale(display: &base::platform::linux::WaylandDisplayInfo) -> ResultType<f64> {
|
||||
// Missing logical geometry means unknown density, as with older senders.
|
||||
let Some((logical_width, logical_height)) = display.logical_size else {
|
||||
return Ok(0.0);
|
||||
};
|
||||
if logical_width <= 0 || logical_height <= 0 || display.width <= 0 || display.height <= 0 {
|
||||
bail!("Invalid Wayland cursor display dimensions");
|
||||
}
|
||||
// Logical geometry is already rotated; the physical mode dimensions are not.
|
||||
let width = if matches!(display.transform, 90 | 270) {
|
||||
display.height
|
||||
} else {
|
||||
display.width
|
||||
};
|
||||
Ok(f64::from(width) / f64::from(logical_width))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cursor_cache_ids_fit_legacy_web_numbers() {
|
||||
for cursor in [1, 123, u64::MAX] {
|
||||
assert_eq!(cache_id(cursor, 0.0), cursor);
|
||||
for scale in [1.0, 1.25, 2.0] {
|
||||
assert!((1..=9_007_199_254_740_991).contains(&cache_id(cursor, scale)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
#[test]
|
||||
fn cursor_density_tracks_fractional_rotation_and_cache_identity() {
|
||||
let display = base::platform::linux::WaylandDisplayInfo {
|
||||
name: "test".into(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
logical_size: Some((600, 960)),
|
||||
refresh_rate: 60000,
|
||||
transform: 90,
|
||||
};
|
||||
assert_eq!(wayland_scale(&display).unwrap(), 4.0 / 3.0);
|
||||
assert_ne!(cache_id(1, 1.0), cache_id(1, 2.0));
|
||||
assert_eq!(cache_id(1, 0.0), 1);
|
||||
}
|
||||
}
|
||||
171
src/platform/linux/cursor/x11_tests.rs
Normal file
171
src/platform/linux/cursor/x11_tests.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use super::super::{get_cursor, get_cursor_data};
|
||||
use hbb_common::ResultType;
|
||||
use x11rb::{
|
||||
connection::Connection, protocol::xproto::*, rust_connection::RustConnection,
|
||||
wrapper::ConnectionExt as _, CURRENT_TIME,
|
||||
};
|
||||
|
||||
struct Settings {
|
||||
connection: RustConnection,
|
||||
owner: Window,
|
||||
property: Atom,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
fn new() -> ResultType<Self> {
|
||||
// This test replaces the settings manager; never run on a real desktop.
|
||||
assert_eq!(
|
||||
std::env::var("RUSTDESK_X11_CURSOR_TEST").as_deref(),
|
||||
Ok("1")
|
||||
);
|
||||
let (connection, screen) = x11rb::connect(None)?;
|
||||
let selection = connection
|
||||
.intern_atom(false, format!("_XSETTINGS_S{screen}").as_bytes())?
|
||||
.reply()?
|
||||
.atom;
|
||||
let property = connection
|
||||
.intern_atom(false, b"_XSETTINGS_SETTINGS")?
|
||||
.reply()?
|
||||
.atom;
|
||||
let owner = connection.generate_id()?;
|
||||
connection
|
||||
.create_window(
|
||||
0,
|
||||
owner,
|
||||
connection.setup().roots[screen].root,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
WindowClass::INPUT_OUTPUT,
|
||||
0,
|
||||
&CreateWindowAux::new(),
|
||||
)?
|
||||
.check()?;
|
||||
connection
|
||||
.set_selection_owner(owner, selection, CURRENT_TIME)?
|
||||
.check()?;
|
||||
Ok(Self {
|
||||
connection,
|
||||
owner,
|
||||
property,
|
||||
})
|
||||
}
|
||||
|
||||
fn property(&self, bytes: &[u8]) {
|
||||
self.connection
|
||||
.change_property8(
|
||||
PropMode::REPLACE,
|
||||
self.owner,
|
||||
self.property,
|
||||
self.property,
|
||||
bytes,
|
||||
)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn scale(&self, value: i32) {
|
||||
const ALIGNMENT: usize = 4;
|
||||
let name = b"Gdk/WindowScalingFactor";
|
||||
let mut bytes = vec![0, 0, 0, 0];
|
||||
bytes.extend(1_u32.to_le_bytes()); // Serial.
|
||||
bytes.extend(1_u32.to_le_bytes()); // One integer setting.
|
||||
bytes.extend([0, 0]);
|
||||
bytes.extend((name.len() as u16).to_le_bytes());
|
||||
bytes.extend(name);
|
||||
bytes.resize(bytes.len().div_ceil(ALIGNMENT) * ALIGNMENT, 0);
|
||||
bytes.extend(1_u32.to_le_bytes());
|
||||
bytes.extend(value.to_le_bytes());
|
||||
self.property(&bytes);
|
||||
}
|
||||
|
||||
fn arrow(&self) {
|
||||
const LEFT_PTR: u16 = 68;
|
||||
const WHITE: u16 = u16::MAX;
|
||||
let font = self.connection.generate_id().unwrap();
|
||||
let cursor = self.connection.generate_id().unwrap();
|
||||
self.connection
|
||||
.open_font(font, b"cursor")
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap();
|
||||
self.connection
|
||||
.create_glyph_cursor(
|
||||
cursor,
|
||||
font,
|
||||
font,
|
||||
LEFT_PTR,
|
||||
LEFT_PTR + 1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
WHITE,
|
||||
WHITE,
|
||||
WHITE,
|
||||
)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap();
|
||||
self.connection
|
||||
.change_window_attributes(
|
||||
self.connection.setup().roots[0].root,
|
||||
&ChangeWindowAttributesAux::new().cursor(cursor),
|
||||
)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(scale: f64) -> u64 {
|
||||
let id = get_cursor().unwrap().expect("Xvfb must have a cursor");
|
||||
let data = get_cursor_data(id).unwrap();
|
||||
assert_eq!((data.id, data.scale), (id, scale));
|
||||
assert!(data.width > 0 && data.height > 0 && !data.colors.is_empty());
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires isolated Xvfb and RUSTDESK_FORCED_DISPLAY_SERVER=x11"]
|
||||
fn x11_metadata_errors_preserve_cursor_delivery_and_recover() {
|
||||
let settings = Settings::new().unwrap();
|
||||
let unknown = cursor(0.0); // Selection owner exists but has no property.
|
||||
settings.scale(2);
|
||||
let known = cursor(2.0);
|
||||
assert_ne!(known, unknown);
|
||||
settings.property(&[0]); // Truncated header.
|
||||
assert_eq!(cursor(0.0), unknown);
|
||||
settings.scale(0); // Invalid density.
|
||||
assert_eq!(cursor(0.0), unknown);
|
||||
settings.scale(2);
|
||||
assert_eq!(cursor(2.0), known);
|
||||
settings.property(&[0]);
|
||||
settings.arrow();
|
||||
assert_ne!(cursor(0.0), unknown); // New shapes still arrive during failure.
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires isolated Xvfb and RUSTDESK_FORCED_DISPLAY_SERVER=x11"]
|
||||
fn x11_metadata_change_between_id_and_bitmap_keeps_snapshot() {
|
||||
let settings = Settings::new().unwrap();
|
||||
settings.scale(2);
|
||||
let id = get_cursor().unwrap().unwrap();
|
||||
settings
|
||||
.connection
|
||||
.destroy_window(settings.owner)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap();
|
||||
let data = get_cursor_data(id).unwrap();
|
||||
assert_eq!((data.id, data.scale), (id, 2.0));
|
||||
let unknown = get_cursor().unwrap().unwrap();
|
||||
assert_ne!(unknown, id);
|
||||
let recovered = Settings::new().unwrap();
|
||||
recovered.scale(2);
|
||||
let data = get_cursor_data(unknown).unwrap();
|
||||
assert_eq!((data.id, data.scale), (unknown, 0.0));
|
||||
assert_eq!(cursor(2.0), id);
|
||||
}
|
||||
135
src/platform/linux/cursor/xsettings.rs
Normal file
135
src/platform/linux/cursor/xsettings.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use std::{convert::TryInto, io};
|
||||
|
||||
const HEADER_SIZE: usize = 12;
|
||||
const ALIGNMENT: usize = 4;
|
||||
const INTEGER: u8 = 0;
|
||||
const STRING: u8 = 1;
|
||||
const COLOR: u8 = 2;
|
||||
const WINDOW_SCALE: &[u8] = b"Gdk/WindowScalingFactor";
|
||||
|
||||
struct Reader<'a> {
|
||||
bytes: &'a [u8],
|
||||
offset: usize,
|
||||
little_endian: bool,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn take(&mut self, length: usize) -> io::Result<&'a [u8]> {
|
||||
let end = self.offset.checked_add(length).ok_or_else(invalid)?;
|
||||
let bytes = self.bytes.get(self.offset..end).ok_or_else(invalid)?;
|
||||
self.offset = end;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn number(&mut self, length: usize) -> io::Result<u32> {
|
||||
let bytes = self.take(length)?;
|
||||
Ok(if self.little_endian {
|
||||
bytes
|
||||
.iter()
|
||||
.rev()
|
||||
.fold(0, |n, byte| (n << 8) | *byte as u32)
|
||||
} else {
|
||||
bytes.iter().fold(0, |n, byte| (n << 8) | *byte as u32)
|
||||
})
|
||||
}
|
||||
|
||||
fn string(&mut self, length: usize) -> io::Result<&'a [u8]> {
|
||||
let bytes = self.take(length)?;
|
||||
self.take((ALIGNMENT - length % ALIGNMENT) % ALIGNMENT)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid() -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Invalid XSETTINGS cursor density",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn scale(bytes: &[u8]) -> io::Result<Option<f64>> {
|
||||
let little_endian = match bytes.first() {
|
||||
Some(0) => true,
|
||||
Some(1) => false,
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
let mut reader = Reader {
|
||||
bytes,
|
||||
offset: 0,
|
||||
little_endian,
|
||||
};
|
||||
reader.take(HEADER_SIZE - ALIGNMENT)?;
|
||||
let count = reader.number(ALIGNMENT)?;
|
||||
let mut scale = None;
|
||||
for _ in 0..count {
|
||||
let kind = reader.number(1)? as u8;
|
||||
reader.take(1)?;
|
||||
let length = reader.number(2)? as usize;
|
||||
let name = reader.string(length)?;
|
||||
reader.take(ALIGNMENT)?; // Last-change serial.
|
||||
match kind {
|
||||
INTEGER => {
|
||||
let value = reader.number(ALIGNMENT)? as i32;
|
||||
if name == WINDOW_SCALE {
|
||||
if value <= 0 {
|
||||
return Err(invalid());
|
||||
}
|
||||
scale = Some(f64::from(value));
|
||||
}
|
||||
}
|
||||
STRING => {
|
||||
let length = reader
|
||||
.number(ALIGNMENT)?
|
||||
.try_into()
|
||||
.map_err(|_| invalid())?;
|
||||
reader.string(length)?;
|
||||
}
|
||||
COLOR => {
|
||||
reader.take(ALIGNMENT * 2)?;
|
||||
}
|
||||
_ => return Err(invalid()),
|
||||
}
|
||||
}
|
||||
Ok(scale)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_scale_ignores_text_dpi_and_checks_the_entire_property() {
|
||||
for little in [false, true] {
|
||||
let mut bytes = vec![u8::from(!little), 0, 0, 0];
|
||||
let number = |n: u32| {
|
||||
if little {
|
||||
n.to_le_bytes()
|
||||
} else {
|
||||
n.to_be_bytes()
|
||||
}
|
||||
};
|
||||
bytes.extend(number(1));
|
||||
bytes.extend(number(2));
|
||||
for (name, value) in [(b"Xft/DPI".as_slice(), 196608), (WINDOW_SCALE, 2)] {
|
||||
bytes.extend([INTEGER, 0]);
|
||||
let length = name.len() as u16;
|
||||
bytes.extend(if little {
|
||||
length.to_le_bytes()
|
||||
} else {
|
||||
length.to_be_bytes()
|
||||
});
|
||||
bytes.extend(name);
|
||||
bytes.resize(bytes.len().div_ceil(ALIGNMENT) * ALIGNMENT, 0);
|
||||
bytes.extend(number(1));
|
||||
bytes.extend(number(value));
|
||||
}
|
||||
assert_eq!(scale(&bytes).unwrap(), Some(2.0));
|
||||
for length in 0..bytes.len() {
|
||||
assert!(scale(&bytes[..length]).is_err());
|
||||
}
|
||||
let end = bytes.len();
|
||||
bytes[end - ALIGNMENT..].copy_from_slice(&number(0));
|
||||
assert!(scale(&bytes).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,12 +35,17 @@ use std::{
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
mod cursor;
|
||||
|
||||
#[cfg(test)]
|
||||
mod cursor_poll_tests;
|
||||
|
||||
// macOS boolean_t is defined as `int` in <mach/boolean.h>
|
||||
type BooleanT = hbb_common::libc::c_int;
|
||||
|
||||
static PRIVILEGES_SCRIPTS_DIR: Dir =
|
||||
include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts");
|
||||
static mut LATEST_SEED: i32 = 0;
|
||||
static mut LATEST_SEED: (i32, f64) = (0, 0.0);
|
||||
|
||||
#[inline]
|
||||
fn get_update_temp_dir() -> PathBuf {
|
||||
@@ -561,23 +566,23 @@ pub fn get_cursor() -> ResultType<Option<u64>> {
|
||||
|
||||
fn unsafe_get_cursor() -> ResultType<Option<u64>> {
|
||||
unsafe {
|
||||
let seed = CGSCurrentCursorSeed();
|
||||
let seed = (CGSCurrentCursorSeed(), cursor::scale()?);
|
||||
if seed == LATEST_SEED {
|
||||
return Ok(None);
|
||||
}
|
||||
let c = get_cursor_id(seed.1)?;
|
||||
LATEST_SEED = seed;
|
||||
Ok(Some(c.1))
|
||||
}
|
||||
let c = get_cursor_id()?;
|
||||
Ok(Some(c.1))
|
||||
}
|
||||
|
||||
pub fn reset_input_cache() {
|
||||
unsafe {
|
||||
LATEST_SEED = 0;
|
||||
LATEST_SEED = (0, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_cursor_id() -> ResultType<(id, u64)> {
|
||||
fn get_cursor_id(scale: f64) -> ResultType<(id, u64)> {
|
||||
unsafe {
|
||||
let c: id = msg_send![class!(NSCursor), currentSystemCursor];
|
||||
if c == nil {
|
||||
@@ -620,21 +625,34 @@ fn get_cursor_id() -> ResultType<(id, u64)> {
|
||||
hcursor += (r + g + b + a) * (255 << i) as f64;
|
||||
}
|
||||
}
|
||||
Ok((c, hcursor as _))
|
||||
Ok((c, cursor::cache_id(hcursor as _, scale)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||
autoreleasepool(|| unsafe_get_cursor_data(hcursor))
|
||||
let result = autoreleasepool(|| unsafe_get_cursor_data(hcursor));
|
||||
if result.is_err() {
|
||||
// A failed capture must be retried even if the seed and density stay unchanged.
|
||||
reset_input_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// https://github.com/stweil/OSXvnc/blob/master/OSXvnc-server/mousecursor.c
|
||||
fn unsafe_get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||
unsafe {
|
||||
let (c, hcursor2) = get_cursor_id()?;
|
||||
// Keep the poll's density if the pointer crosses displays before capture.
|
||||
let scale = LATEST_SEED.1;
|
||||
let (c, hcursor2) = get_cursor_id(scale)?;
|
||||
if hcursor != hcursor2 {
|
||||
bail!("cursor changed");
|
||||
}
|
||||
// NSImage.size is in points; using it as bitmap dimensions can crop Retina
|
||||
// artwork. Render the full image and convert its hotspot to pixels;
|
||||
// keep the existing 1x sampling path below.
|
||||
if scale > 1.0 {
|
||||
return cursor::data(c, hcursor, scale);
|
||||
}
|
||||
let hotspot: NSPoint = msg_send![c, hotSpot];
|
||||
let img: id = msg_send![c, image];
|
||||
let size: NSSize = msg_send![img, size];
|
||||
@@ -668,6 +686,7 @@ fn unsafe_get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||
let g: f64 = msg_send![color, greenComponent];
|
||||
let b: f64 = msg_send![color, blueComponent];
|
||||
let a: f64 = msg_send![color, alphaComponent];
|
||||
// Keep straight RGBA on the wire for older Web/Sciter receivers.
|
||||
colors.push((r * 255.) as _);
|
||||
colors.push((g * 255.) as _);
|
||||
colors.push((b * 255.) as _);
|
||||
@@ -681,6 +700,7 @@ fn unsafe_get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||
hoty: hotspot.y as _,
|
||||
width: size.width as _,
|
||||
height: size.height as _,
|
||||
scale: 1.0,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
290
src/platform/macos/cursor.rs
Normal file
290
src/platform/macos/cursor.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
use super::{CursorData, ResultType};
|
||||
use cocoa::{
|
||||
appkit::NSCompositingOperation,
|
||||
base::{id, nil, NO, YES},
|
||||
foundation::{NSInteger, NSPoint, NSRect, NSSize, NSString},
|
||||
};
|
||||
use hbb_common::{anyhow::Context, bail};
|
||||
use objc::{class, msg_send, rc::StrongPtr, sel, sel_impl};
|
||||
use std::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
hash::{Hash, Hasher},
|
||||
ptr, slice,
|
||||
};
|
||||
|
||||
const CHANNELS: usize = 4;
|
||||
const BITS_PER_SAMPLE: NSInteger = 8;
|
||||
|
||||
pub(super) fn scale() -> ResultType<f64> {
|
||||
if !*scrap::quartz::ENABLE_RETINA.lock().unwrap() {
|
||||
return Ok(1.0);
|
||||
}
|
||||
unsafe {
|
||||
let point: NSPoint = msg_send![class!(NSEvent), mouseLocation];
|
||||
let screens: id = msg_send![class!(NSScreen), screens];
|
||||
let count: usize = msg_send![screens, count];
|
||||
for index in 0..count {
|
||||
let screen: id = msg_send![screens, objectAtIndex: index];
|
||||
let frame: NSRect = msg_send![screen, frame];
|
||||
// AppKit's bottom-left coordinates include the upper screen edge.
|
||||
if point.x >= frame.origin.x
|
||||
&& point.y > frame.origin.y
|
||||
&& point.x < frame.origin.x + frame.size.width
|
||||
&& point.y <= frame.origin.y + frame.size.height
|
||||
{
|
||||
return Ok(msg_send![screen, backingScaleFactor]);
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("No macOS display contains the cursor")
|
||||
}
|
||||
|
||||
pub(super) fn cache_id(cursor: u64, scale: f64) -> u64 {
|
||||
// Legacy Web decoders require JS-safe integers; zero is the service's initial ID.
|
||||
const MAX_CURSOR_ID: u64 = (1 << 53) - 1;
|
||||
let mut hash = DefaultHasher::new();
|
||||
(cursor, scale.to_bits()).hash(&mut hash);
|
||||
hash.finish() % MAX_CURSOR_ID + 1
|
||||
}
|
||||
|
||||
unsafe fn bitmap(size: NSSize) -> ResultType<StrongPtr> {
|
||||
let color_space = StrongPtr::new(NSString::alloc(nil).init_str("NSDeviceRGBColorSpace"));
|
||||
let bitmap: id = msg_send![class!(NSBitmapImageRep), alloc];
|
||||
let bitmap: id = msg_send![bitmap,
|
||||
initWithBitmapDataPlanes: ptr::null_mut::<*mut u8>()
|
||||
pixelsWide: size.width as NSInteger pixelsHigh: size.height as NSInteger
|
||||
bitsPerSample: BITS_PER_SAMPLE samplesPerPixel: CHANNELS as NSInteger
|
||||
hasAlpha: YES isPlanar: NO colorSpaceName: *color_space
|
||||
bitmapFormat: 0usize bytesPerRow: (size.width as usize * CHANNELS) as NSInteger
|
||||
bitsPerPixel: BITS_PER_SAMPLE * CHANNELS as NSInteger];
|
||||
if bitmap == nil {
|
||||
bail!("Could not allocate the macOS cursor bitmap");
|
||||
}
|
||||
Ok(StrongPtr::new(bitmap))
|
||||
}
|
||||
|
||||
unsafe fn render(image: id, bitmap: id, size: NSSize) -> ResultType<()> {
|
||||
let context: id =
|
||||
msg_send![class!(NSGraphicsContext), graphicsContextWithBitmapImageRep: bitmap];
|
||||
if context == nil {
|
||||
bail!("Could not create the macOS cursor graphics context");
|
||||
}
|
||||
let (): () = msg_send![class!(NSGraphicsContext), saveGraphicsState];
|
||||
let (): () = msg_send![class!(NSGraphicsContext), setCurrentContext: context];
|
||||
// Drawing at the pixel size lets AppKit select the matching image representation.
|
||||
let (): () = msg_send![image,
|
||||
drawInRect: NSRect::new(NSPoint::new(0.0, 0.0), size)
|
||||
fromRect: NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(0.0, 0.0))
|
||||
operation: NSCompositingOperation::NSCompositeCopy fraction: 1.0f64];
|
||||
let (): () = msg_send![class!(NSGraphicsContext), restoreGraphicsState];
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) unsafe fn data(cursor: id, id: u64, scale: f64) -> ResultType<CursorData> {
|
||||
// Older receivers ignore density. Keep their image logical-sized while
|
||||
// retaining the complete physical artwork for density-aware controllers.
|
||||
let mut legacy = physical_data(cursor, id, 1.0)?;
|
||||
if scale > 1.0 {
|
||||
legacy.high_resolution = Some(physical_data(cursor, id, scale)?).into();
|
||||
}
|
||||
Ok(legacy)
|
||||
}
|
||||
|
||||
unsafe fn physical_data(cursor: id, id: u64, scale: f64) -> ResultType<CursorData> {
|
||||
let image: id = msg_send![cursor, image];
|
||||
let logical: NSSize = msg_send![image, size];
|
||||
let size = NSSize::new(
|
||||
(logical.width * scale).round(),
|
||||
(logical.height * scale).round(),
|
||||
);
|
||||
if !size.width.is_finite()
|
||||
|| !size.height.is_finite()
|
||||
|| size.width <= 0.0
|
||||
|| size.height <= 0.0
|
||||
|| size.width > i32::MAX as f64
|
||||
|| size.height > i32::MAX as f64
|
||||
{
|
||||
bail!("Invalid macOS cursor dimensions");
|
||||
}
|
||||
let length = (size.width as usize)
|
||||
.checked_mul(size.height as usize)
|
||||
.and_then(|pixels| pixels.checked_mul(CHANNELS))
|
||||
.context("Cursor bitmap size overflow")?;
|
||||
let bitmap = bitmap(size)?;
|
||||
render(image, *bitmap, size)?;
|
||||
let pixels: *const u8 = msg_send![*bitmap, bitmapData];
|
||||
if pixels.is_null() {
|
||||
bail!("Could not read the macOS cursor bitmap");
|
||||
}
|
||||
let hotspot: NSPoint = msg_send![cursor, hotSpot];
|
||||
Ok(CursorData {
|
||||
id,
|
||||
colors: straight_rgba(slice::from_raw_parts(pixels, length)).into(),
|
||||
// A valid fractional hotspot near an edge can round past the last pixel.
|
||||
hotx: (hotspot.x * size.width / logical.width)
|
||||
.round()
|
||||
.min(size.width - 1.0) as _,
|
||||
hoty: (hotspot.y * size.height / logical.height)
|
||||
.round()
|
||||
.min(size.height - 1.0) as _,
|
||||
width: size.width as _,
|
||||
height: size.height as _,
|
||||
scale,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn straight_rgba(pixels: &[u8]) -> Vec<u8> {
|
||||
// AppKit renders premultiplied pixels, but macOS cursor packets have always
|
||||
// used straight alpha. Density metadata does not negotiate a new format.
|
||||
const MAX_CHANNEL: u16 = u8::MAX as u16;
|
||||
let mut colors = pixels.to_vec();
|
||||
for pixel in colors.chunks_exact_mut(CHANNELS) {
|
||||
let alpha = u16::from(pixel[CHANNELS - 1]);
|
||||
if alpha == 0 {
|
||||
continue;
|
||||
}
|
||||
for channel in &mut pixel[..CHANNELS - 1] {
|
||||
*channel =
|
||||
((u16::from(*channel) * MAX_CHANNEL + alpha / 2) / alpha).min(MAX_CHANNEL) as u8;
|
||||
}
|
||||
}
|
||||
colors
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compat_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use objc::rc::autoreleasepool;
|
||||
|
||||
#[test]
|
||||
fn retina_cursor_uses_complete_high_resolution_artwork() {
|
||||
autoreleasepool(|| unsafe {
|
||||
let logical = NSSize::new(9.0, 18.0);
|
||||
let image: id = msg_send![class!(NSImage), alloc];
|
||||
let image = StrongPtr::new(msg_send![image, initWithSize: logical]);
|
||||
let mut expected = Vec::new();
|
||||
for scale in [1, 2] {
|
||||
let width = logical.width as usize * scale;
|
||||
let height = logical.height as usize * scale;
|
||||
let rep = bitmap(NSSize::new(width as f64, height as f64)).unwrap();
|
||||
let mut pixels = vec![0; width * height * CHANNELS];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
if y == 0 || y == height - 1 || x == width / 2 {
|
||||
let color = if y == 0 {
|
||||
[255, 0, 0, 255]
|
||||
} else {
|
||||
[0, 255, 0, 255]
|
||||
};
|
||||
pixels[(y * width + x) * CHANNELS..(y * width + x + 1) * CHANNELS]
|
||||
.copy_from_slice(&color);
|
||||
}
|
||||
}
|
||||
}
|
||||
let buffer: *mut u8 = msg_send![*rep, bitmapData];
|
||||
ptr::copy_nonoverlapping(pixels.as_ptr(), buffer, pixels.len());
|
||||
let (): () = msg_send![*rep, setSize: logical];
|
||||
let (): () = msg_send![*image, addRepresentation: *rep];
|
||||
if scale == 2 {
|
||||
expected = pixels;
|
||||
}
|
||||
}
|
||||
let cursor: id = msg_send![class!(NSCursor), alloc];
|
||||
let cursor = StrongPtr::new(
|
||||
msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(4.0, 9.0)],
|
||||
);
|
||||
let result = physical_data(*cursor, 1, 2.0).unwrap();
|
||||
assert_eq!(
|
||||
(result.width, result.height, result.hotx, result.hoty),
|
||||
(18, 36, 8, 18)
|
||||
);
|
||||
assert_eq!(result.colors.as_ref(), expected.as_slice());
|
||||
assert_retina_hotspots(*image, &expected);
|
||||
});
|
||||
}
|
||||
|
||||
unsafe fn assert_retina_hotspots(image: id, expected: &[u8]) {
|
||||
for (point, pixels) in [
|
||||
((8.8, 17.8), (17, 35)),
|
||||
((8.8, 9.0), (17, 18)),
|
||||
((4.0, 17.8), (8, 35)),
|
||||
((0.0, 0.0), (0, 0)),
|
||||
] {
|
||||
let c: id = msg_send![class!(NSCursor), alloc];
|
||||
let c = StrongPtr::new(msg_send![c,
|
||||
initWithImage: image hotSpot: NSPoint::new(point.0, point.1)]);
|
||||
let actual: NSPoint = msg_send![*c, hotSpot];
|
||||
assert_eq!((actual.x, actual.y), point);
|
||||
let result = physical_data(*c, 1, 2.0).unwrap();
|
||||
assert_eq!((result.hotx, result.hoty), pixels);
|
||||
assert_eq!(result.colors.as_ref(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retina_cursor_keeps_straight_alpha_for_legacy_receivers() {
|
||||
const SIDE: usize = 4;
|
||||
const SCALE: f64 = 2.0;
|
||||
const PREMULTIPLIED: [[u8; CHANNELS]; SIDE] = [
|
||||
[128, 128, 128, 128],
|
||||
[64, 32, 16, 128],
|
||||
[240, 100, 20, 255],
|
||||
[0, 0, 0, 0],
|
||||
];
|
||||
const STRAIGHT: [[u8; CHANNELS]; SIDE] = [
|
||||
[255, 255, 255, 128],
|
||||
[128, 64, 32, 128],
|
||||
[240, 100, 20, 255],
|
||||
[0, 0, 0, 0],
|
||||
];
|
||||
autoreleasepool(|| unsafe {
|
||||
let logical = NSSize::new(SIDE as f64 / SCALE, SIDE as f64 / SCALE);
|
||||
let image: id = msg_send![class!(NSImage), alloc];
|
||||
let image = StrongPtr::new(msg_send![image, initWithSize: logical]);
|
||||
let rep = bitmap(NSSize::new(SIDE as f64, SIDE as f64)).unwrap();
|
||||
let pixels: Vec<u8> = (0..SIDE * SIDE)
|
||||
.flat_map(|index| PREMULTIPLIED[index % SIDE])
|
||||
.collect();
|
||||
let buffer: *mut u8 = msg_send![*rep, bitmapData];
|
||||
ptr::copy_nonoverlapping(pixels.as_ptr(), buffer, pixels.len());
|
||||
let (): () = msg_send![*rep, setSize: logical];
|
||||
let (): () = msg_send![*image, addRepresentation: *rep];
|
||||
let cursor: id = msg_send![class!(NSCursor), alloc];
|
||||
let cursor = StrongPtr::new(
|
||||
msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(1.0, 1.0)],
|
||||
);
|
||||
let result = physical_data(*cursor, 1, SCALE).unwrap();
|
||||
// Older Sciter receivers encode the received bytes directly as PNG.
|
||||
let mut png = Vec::new();
|
||||
repng::encode(
|
||||
&mut png,
|
||||
result.width as _,
|
||||
result.height as _,
|
||||
&result.colors,
|
||||
)
|
||||
.unwrap();
|
||||
let decoded = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
for (index, pixel) in decoded.pixels().enumerate() {
|
||||
assert_eq!(pixel.0, STRAIGHT[index % SIDE]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_cache_changes_with_display_scale() {
|
||||
assert_ne!(cache_id(123, 1.0), cache_id(123, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_cache_ids_fit_legacy_web_numbers() {
|
||||
for cursor in [1, 123, u64::MAX] {
|
||||
for scale in [1.0, 1.25, 2.0] {
|
||||
assert!((1..=9_007_199_254_740_991).contains(&cache_id(cursor, scale)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/platform/macos/cursor/compat_tests.rs
Normal file
35
src/platform/macos/cursor/compat_tests.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use super::*;
|
||||
use hbb_common::protobuf::Message;
|
||||
use objc::rc::autoreleasepool;
|
||||
|
||||
#[test]
|
||||
fn retina_export_preserves_legacy_dimensions_and_hotspot() {
|
||||
autoreleasepool(|| unsafe {
|
||||
let logical = NSSize::new(32.0, 32.0);
|
||||
let image: id = msg_send![class!(NSImage), alloc];
|
||||
let image = StrongPtr::new(msg_send![image, initWithSize: logical]);
|
||||
let rep = bitmap(NSSize::new(64.0, 64.0)).unwrap();
|
||||
let buffer: *mut u8 = msg_send![*rep, bitmapData];
|
||||
ptr::write_bytes(buffer, 255, 64 * 64 * CHANNELS);
|
||||
let (): () = msg_send![*rep, setSize: logical];
|
||||
let (): () = msg_send![*image, addRepresentation: *rep];
|
||||
let cursor: id = msg_send![class!(NSCursor), alloc];
|
||||
let cursor = StrongPtr::new(msg_send![cursor,
|
||||
initWithImage: *image hotSpot: NSPoint::new(8.0, 12.0)]);
|
||||
|
||||
let exported = data(*cursor, 123, 2.0).unwrap();
|
||||
let legacy = CursorData::parse_from_bytes(&exported.write_to_bytes().unwrap()).unwrap();
|
||||
assert_eq!((legacy.width, legacy.height), (32, 32));
|
||||
assert_eq!((legacy.hotx, legacy.hoty), (8, 12));
|
||||
assert_eq!(legacy.colors.len(), 32 * 32 * CHANNELS);
|
||||
assert_eq!(legacy.scale, 1.0);
|
||||
assert!(legacy.colors.iter().all(|channel| *channel == 255));
|
||||
let physical = legacy.high_resolution.as_ref().unwrap();
|
||||
assert_eq!((physical.width, physical.height), (64, 64));
|
||||
assert_eq!((physical.hotx, physical.hoty), (16, 24));
|
||||
assert_eq!(physical.colors.len(), 64 * 64 * CHANNELS);
|
||||
assert_eq!((physical.id, physical.scale), (legacy.id, 2.0));
|
||||
assert!(physical.colors.iter().all(|channel| *channel == 255));
|
||||
assert!(data(*cursor, 123, 1.0).unwrap().high_resolution.is_none());
|
||||
});
|
||||
}
|
||||
17
src/platform/macos/cursor_poll_tests.rs
Normal file
17
src/platform/macos/cursor_poll_tests.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capture_failure_invalidates_the_density_poll() {
|
||||
let previous = unsafe { LATEST_SEED };
|
||||
unsafe {
|
||||
LATEST_SEED = (1, 2.0);
|
||||
}
|
||||
// Zero cannot be a macOS cursor ID, even if AppKit has no current cursor.
|
||||
let result = get_cursor_data(0);
|
||||
let after_failure = unsafe { LATEST_SEED };
|
||||
unsafe {
|
||||
LATEST_SEED = previous;
|
||||
}
|
||||
assert!(result.is_err());
|
||||
assert_eq!(after_failure, (0, 0.0));
|
||||
}
|
||||
@@ -413,7 +413,9 @@ extern "C"
|
||||
{
|
||||
auto in = in0;
|
||||
auto out0_end = out0 + out0_size;
|
||||
auto offset = width * 4 + 4;
|
||||
// The output adds a pixel on each side; place the source at (1, 1)
|
||||
// using the padded stride to match the caller's hotspot +1 adjustment.
|
||||
auto offset = (width + 2) * 4 + 4;
|
||||
auto out = out0 + offset;
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
|
||||
@@ -4750,6 +4750,44 @@ pub(super) fn get_pids_with_first_arg_by_wmic<S1: AsRef<str>, S2: AsRef<str>>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cursor_outline_preserves_pixels_at_hotspot_offset() {
|
||||
const CHANNELS: usize = 4;
|
||||
const BORDER: usize = 1;
|
||||
const WIDTH: usize = 3;
|
||||
const HEIGHT: usize = 9;
|
||||
const INK: [u8; CHANNELS] = [32, 64, 96, 255];
|
||||
let mut source = vec![0; WIDTH * HEIGHT * CHANNELS];
|
||||
for y in 0..HEIGHT {
|
||||
for x in 0..WIDTH {
|
||||
if x == WIDTH / 2 || y == 0 || y == HEIGHT - 1 {
|
||||
let offset = (y * WIDTH + x) * CHANNELS;
|
||||
source[offset..offset + CHANNELS].copy_from_slice(&INK);
|
||||
}
|
||||
}
|
||||
}
|
||||
let stride = WIDTH + BORDER * 2;
|
||||
let mut outlined = vec![0; stride * (HEIGHT + BORDER * 2) * CHANNELS];
|
||||
unsafe {
|
||||
drawOutline(
|
||||
outlined.as_mut_ptr(),
|
||||
source.as_ptr(),
|
||||
WIDTH as _,
|
||||
HEIGHT as _,
|
||||
outlined.len() as _,
|
||||
);
|
||||
}
|
||||
for y in 0..HEIGHT {
|
||||
for x in 0..WIDTH {
|
||||
let input = (y * WIDTH + x) * CHANNELS;
|
||||
if source[input + CHANNELS - 1] != 0 {
|
||||
let output = ((y + BORDER) * stride + x + BORDER) * CHANNELS;
|
||||
assert_eq!(&outlined[output..output + CHANNELS], &INK, "({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only reusable Win32 HANDLE RAII helper.
|
||||
// If a future non-test path needs the same pattern, move it out of this test module.
|
||||
//
|
||||
|
||||
@@ -14,6 +14,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod cursor_metadata;
|
||||
|
||||
const HANDSHAKE_TIMEOUT_MS: u64 = 3000;
|
||||
const DRM_CONNECT_TIMEOUT_MS: u64 = 1000;
|
||||
/// The service may hold the list back while it wakes sleeping displays: ~3.6s (DRM_WAKE_*).
|
||||
@@ -955,23 +957,63 @@ fn fold_cursor_id(id: u64, t: i32) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn with_drm_cursor<T>(f: impl Fn(&DrmCursorData) -> T) -> Option<T> {
|
||||
let map = DRM_CURSOR.lock().unwrap();
|
||||
map.values()
|
||||
.map(|(_, c)| c)
|
||||
.find(|c| c.id != scrap::drm_reader::HIDDEN_CURSOR_ID)
|
||||
.or_else(|| map.values().map(|(_, c)| c).next())
|
||||
.map(f)
|
||||
/// Snapshot of the DRM hardware cursor and optional display metadata. Pixels retain the
|
||||
/// premultiplied format used by the XFixes path.
|
||||
pub fn drm_cursor_snapshot<T>(
|
||||
f: impl Fn(&DrmCursorData) -> T,
|
||||
) -> Option<(T, Option<base::platform::linux::WaylandDisplayInfo>)> {
|
||||
use scrap::wayland::display::{get_cached_displays, wayland_snapshot_generation};
|
||||
|
||||
// Keep cursor identity and output together, then release the map before DRM_STATE.
|
||||
let (value, display, epoch, hidden) = {
|
||||
let map = DRM_CURSOR.lock().unwrap();
|
||||
let (display, (epoch, cursor)) = map
|
||||
.iter()
|
||||
.find(|(_, (_, cursor))| cursor.id != scrap::drm_reader::HIDDEN_CURSOR_ID)
|
||||
.or_else(|| map.iter().next())?;
|
||||
(
|
||||
f(cursor),
|
||||
*display,
|
||||
*epoch,
|
||||
cursor.id == scrap::drm_reader::HIDDEN_CURSOR_ID,
|
||||
)
|
||||
};
|
||||
let monitor = if hidden {
|
||||
None
|
||||
} else {
|
||||
cursor_metadata::monitor(
|
||||
cursor_metadata::Context {
|
||||
display,
|
||||
epoch,
|
||||
layout_generation: wayland_snapshot_generation(),
|
||||
},
|
||||
get_cached_displays(),
|
||||
|wayland| {
|
||||
cursor_monitor(
|
||||
display.max(0) as usize,
|
||||
&DRM_STATE.lock().unwrap(),
|
||||
&wayland.displays,
|
||||
)
|
||||
},
|
||||
)
|
||||
};
|
||||
Some((value, monitor))
|
||||
}
|
||||
|
||||
pub fn drm_cursor_id() -> Option<u64> {
|
||||
with_drm_cursor(|c| c.id)
|
||||
}
|
||||
|
||||
/// Snapshot of the DRM hardware cursor, or None. The pixels are premultiplied ARGB and are passed
|
||||
/// through as-is, like the XFixes path, so the client sees one cursor format from either backend.
|
||||
pub fn drm_cursor() -> Option<DrmCursorData> {
|
||||
with_drm_cursor(|c| c.clone())
|
||||
fn cursor_monitor(
|
||||
display: usize,
|
||||
state: &ProbeState,
|
||||
monitors: &[base::platform::linux::WaylandDisplayInfo],
|
||||
) -> Option<base::platform::linux::WaylandDisplayInfo> {
|
||||
let ProbeState::Available(_, displays) = state else {
|
||||
return None;
|
||||
};
|
||||
// Reserve every connector's name match before guessing this cursor's density.
|
||||
let index = identity_matches(displays, monitors)
|
||||
.get(display)
|
||||
.copied()
|
||||
.flatten()?;
|
||||
monitors.get(index).cloned()
|
||||
}
|
||||
|
||||
enum ProbeState {
|
||||
@@ -2270,6 +2312,35 @@ mod drm_capturer_tests {
|
||||
assert!(m2[0].is_none() && m2[1].is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_monitor_reserves_other_displays_before_guessing_density() {
|
||||
let state = ProbeState::Available(
|
||||
Instant::now(),
|
||||
vec![
|
||||
drm_display("DSI-1", 1920, 1080),
|
||||
drm_display("HDMI-A-1", 1920, 1080),
|
||||
],
|
||||
);
|
||||
for other_width in [2560, 1920] {
|
||||
let mut monitors = vec![
|
||||
wl_display("HDMI-1", 0, 0, 1920, 1080),
|
||||
wl_display("Unknown-9", 960, 0, other_width, 1080),
|
||||
];
|
||||
monitors[0].logical_size = Some((960, 540));
|
||||
let selected = cursor_monitor(0, &state, &monitors);
|
||||
if other_width == 2560 {
|
||||
assert!(selected.is_none(), "DSI must not inherit HDMI's 2x density");
|
||||
} else {
|
||||
let selected = selected.unwrap();
|
||||
assert_eq!(selected.name, "Unknown-9");
|
||||
assert_eq!(selected.logical_size, Some((1920, 1080)));
|
||||
}
|
||||
let hdmi = cursor_monitor(1, &state, &monitors).unwrap();
|
||||
assert_eq!(hdmi.name, "HDMI-1");
|
||||
assert_eq!(hdmi.logical_size, Some((960, 540)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_are_matched_by_name_across_the_drm_naming_difference() {
|
||||
let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)];
|
||||
|
||||
123
src/server/drm_capturer/cursor_metadata.rs
Normal file
123
src/server/drm_capturer/cursor_metadata.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use base::platform::linux::WaylandDisplayInfo;
|
||||
use scrap::wayland::display::{CachedDisplays, Displays};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct Context {
|
||||
pub display: i32,
|
||||
pub epoch: u64,
|
||||
pub layout_generation: u64,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static LAST_MONITOR: Cell<Option<(Context, WaylandDisplayInfo)>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
pub(super) fn monitor(
|
||||
context: Context,
|
||||
snapshot: CachedDisplays,
|
||||
resolve: impl FnOnce(&Displays) -> Option<WaylandDisplayInfo>,
|
||||
) -> Option<WaylandDisplayInfo> {
|
||||
// ID polling and bitmap retrieval run on the same cursor-service thread.
|
||||
// Only contention may reuse metadata, and only for this output, stream and layout.
|
||||
LAST_MONITOR.with(|last| match snapshot {
|
||||
CachedDisplays::Busy => {
|
||||
let cached = last.take();
|
||||
let monitor = cached
|
||||
.as_ref()
|
||||
.filter(|(key, _)| *key == context)
|
||||
.map(|(_, monitor)| monitor.clone());
|
||||
last.set(cached);
|
||||
monitor
|
||||
}
|
||||
CachedDisplays::Ready(displays) => {
|
||||
let monitor = displays.as_deref().and_then(resolve);
|
||||
// An accessible cache with no matching metadata invalidates the old density.
|
||||
last.set(monitor.clone().map(|monitor| (context, monitor)));
|
||||
monitor
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
const CONTEXT: Context = Context {
|
||||
display: 0,
|
||||
epoch: 7,
|
||||
layout_generation: 11,
|
||||
};
|
||||
|
||||
fn displays(logical_width: i32) -> CachedDisplays {
|
||||
CachedDisplays::Ready(Some(Arc::new(Displays {
|
||||
primary: 0,
|
||||
displays: vec![WaylandDisplayInfo {
|
||||
name: "DP-1".into(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
logical_size: Some((logical_width, logical_width * 800 / 1280)),
|
||||
refresh_rate: 60000,
|
||||
transform: 0,
|
||||
}],
|
||||
})))
|
||||
}
|
||||
|
||||
fn read(context: Context, snapshot: CachedDisplays) -> Option<WaylandDisplayInfo> {
|
||||
monitor(context, snapshot, |displays| {
|
||||
displays.displays.first().cloned()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn busy_cache_preserves_metadata_until_a_completed_read() {
|
||||
for width in [640, 1280] {
|
||||
let expected = read(CONTEXT, displays(width)).unwrap().logical_size;
|
||||
for _ in 0..5 {
|
||||
let retained = monitor(CONTEXT, CachedDisplays::Busy, |_| {
|
||||
panic!("a busy cursor lookup must not resolve displays")
|
||||
});
|
||||
assert_eq!(retained.unwrap().logical_size, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn busy_cache_does_not_borrow_another_output_stream_or_layout() {
|
||||
read(CONTEXT, displays(640));
|
||||
for changed in [
|
||||
Context {
|
||||
display: 1,
|
||||
..CONTEXT
|
||||
},
|
||||
Context {
|
||||
epoch: 8,
|
||||
..CONTEXT
|
||||
},
|
||||
Context {
|
||||
layout_generation: 12,
|
||||
..CONTEXT
|
||||
},
|
||||
] {
|
||||
assert!(read(changed, CachedDisplays::Busy).is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
read(CONTEXT, CachedDisplays::Busy).unwrap().logical_size,
|
||||
Some((640, 400))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_unmatched_metadata_invalidates_retained_density() {
|
||||
read(CONTEXT, displays(640));
|
||||
assert!(read(CONTEXT, CachedDisplays::Ready(None)).is_none());
|
||||
assert!(read(CONTEXT, CachedDisplays::Busy).is_none());
|
||||
|
||||
read(CONTEXT, displays(640));
|
||||
assert!(monitor(CONTEXT, displays(640), |_| None).is_none());
|
||||
assert!(read(CONTEXT, CachedDisplays::Busy).is_none());
|
||||
}
|
||||
}
|
||||
@@ -422,6 +422,9 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()>
|
||||
#[cfg(not(all(target_os = "linux", feature = "drm")))]
|
||||
let cache_key = hcursor;
|
||||
data.colors = hbb_common::compress::compress(&data.colors[..]).into();
|
||||
if let Some(physical) = data.high_resolution.as_mut() {
|
||||
physical.colors = hbb_common::compress::compress(&physical.colors).into();
|
||||
}
|
||||
let mut tmp = Message::new();
|
||||
tmp.set_cursor_data(data);
|
||||
msg = Arc::new(tmp);
|
||||
|
||||
Reference in New Issue
Block a user