mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-18 02:10:59 +03:00
revert(cursor): return to f5b98b32 before splitting plugin changes
This commit is contained in:
@@ -19,7 +19,6 @@ import '../../models/input_model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../../utils/cursor_size.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
@@ -1089,21 +1088,6 @@ class ImagePaint extends StatefulWidget {
|
||||
|
||||
class _ImagePaintState extends State<ImagePaint> {
|
||||
bool _lastRemoteCursorMoved = false;
|
||||
final _localCursorSize = LocalCursorSize();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_localCursorSize.addListener(_cursorSizeChanged);
|
||||
}
|
||||
|
||||
void _cursorSizeChanged() => setState(() {});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localCursorSize.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get id => widget.id;
|
||||
RxBool get zoomCursor => widget.zoomCursor;
|
||||
@@ -1121,29 +1105,22 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
// changes, so read it live to follow the window across monitors.
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
|
||||
bool isViewScaled() =>
|
||||
c.viewStyle.style == kRemoteViewStyleAdaptive ||
|
||||
c.viewStyle.style == kRemoteViewStyleCustom;
|
||||
bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive;
|
||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
|
||||
mouseRegion({child}) => Obx(() {
|
||||
final useLocalSize = !isWeb &&
|
||||
(isLinux || isMacOS || isWindows) &&
|
||||
!zoomCursor.value &&
|
||||
isViewScaled();
|
||||
if (useLocalSize) _localCursorSize.ensureLoaded(dpr);
|
||||
double getCursorScale() {
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
var cursorScale = 1.0;
|
||||
if (isWindows) {
|
||||
// debug win10
|
||||
if (zoomCursor.value && isViewScaled()) {
|
||||
if (zoomCursor.value && isViewAdaptive()) {
|
||||
cursorScale = s * c.devicePixelRatio;
|
||||
}
|
||||
} else {
|
||||
if (zoomCursor.value || isViewOriginal()) {
|
||||
cursorScale = s;
|
||||
} else if (isLinux || isMacOS) {
|
||||
} else {
|
||||
// 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
|
||||
@@ -1172,16 +1149,11 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
_firstEnterImage.value = true;
|
||||
}
|
||||
return _buildCustomCursor(
|
||||
context, getCursorScale(),
|
||||
useLocalSize: useLocalSize);
|
||||
context, getCursorScale());
|
||||
}
|
||||
}())
|
||||
: _buildDisabledCursor(context, getCursorScale(),
|
||||
useLocalSize: useLocalSize)
|
||||
: _buildDisabledCursor(context, getCursorScale())
|
||||
: MouseCursor.defer,
|
||||
onEnter: (_) {
|
||||
if (useLocalSize) _localCursorSize.refresh();
|
||||
},
|
||||
onHover: (evt) {},
|
||||
child: child);
|
||||
});
|
||||
@@ -1294,19 +1266,15 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
);
|
||||
}
|
||||
|
||||
MouseCursor _buildCustomCursor(BuildContext context, double scale,
|
||||
{bool useLocalSize = false}) {
|
||||
MouseCursor _buildCustomCursor(BuildContext context, double scale) {
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
final cache = cursor.cache ?? preDefaultCursor.cache;
|
||||
cache?.localSize = useLocalSize ? _localCursorSize.value : null;
|
||||
return buildCursorOfCache(cursor, scale, cache);
|
||||
}
|
||||
|
||||
MouseCursor _buildDisabledCursor(BuildContext context, double scale,
|
||||
{bool useLocalSize = false}) {
|
||||
MouseCursor _buildDisabledCursor(BuildContext context, double scale) {
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
final cache = preForbiddenCursor.cache;
|
||||
cache?.localSize = useLocalSize ? _localCursorSize.value : null;
|
||||
return buildCursorOfCache(cursor, scale, cache);
|
||||
}
|
||||
|
||||
@@ -1425,9 +1393,8 @@ class CursorPaint extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final imageOffset = _softwareImageOffset(c);
|
||||
double cx = imageOffset?.dx ?? c.x;
|
||||
double cy = imageOffset?.dy ?? c.y;
|
||||
double cx = c.x;
|
||||
double cy = c.y;
|
||||
if (c.viewStyle.style == kRemoteViewStyleOriginal &&
|
||||
c.scrollStyle == ScrollStyle.scrollbar) {
|
||||
final rect = c.parent.target!.ffiModel.rect;
|
||||
@@ -1446,44 +1413,38 @@ class CursorPaint extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final image = m.image ?? preDefaultCursor.image;
|
||||
final nativePixels = isWindows ? MediaQuery.devicePixelRatioOf(context) : 1.0;
|
||||
double scale = c.scale;
|
||||
if (image != null && scale * nativePixels != 1.0) {
|
||||
final sx = kMinCursorSize / (image.width * nativePixels);
|
||||
final sy = kMinCursorSize / (image.height * nativePixels);
|
||||
final minimumScale = sx > sy ? sx : sy;
|
||||
if (scale < minimumScale) scale = minimumScale;
|
||||
double x = m.x * c.scale + cx - hotx;
|
||||
double y = m.y * c.scale + cy - hoty;
|
||||
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;
|
||||
} else if (!isWindows) {
|
||||
// Keep the painted cursor the same physical size as the native one
|
||||
// built by getCursorScale() above, including its min-size clamp.
|
||||
scale = 1.0 / MediaQuery.devicePixelRatioOf(context);
|
||||
final image = m.image ?? preDefaultCursor.image;
|
||||
if (scale != 1.0 &&
|
||||
image != null &&
|
||||
((image.width * scale).toInt() < kMinCursorSize ||
|
||||
(image.height * scale).toInt() < kMinCursorSize)) {
|
||||
final sw = kMinCursorSize / image.width;
|
||||
final sh = kMinCursorSize / image.height;
|
||||
scale = sw < sh ? sh : sw;
|
||||
}
|
||||
x = (m.x * c.scale + cx) / scale - hotx;
|
||||
y = (m.y * c.scale + cy) / scale - hoty;
|
||||
}
|
||||
final x = (m.x * c.scale + cx) / scale - hotx;
|
||||
final y = (m.y * c.scale + cy) / scale - hoty;
|
||||
|
||||
return CustomPaint(
|
||||
painter: ImagePainter(
|
||||
image: image,
|
||||
image: m.image ?? preDefaultCursor.image,
|
||||
x: x,
|
||||
y: y,
|
||||
scale: scale,
|
||||
useIntegerPosition: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ import 'package:vector_math/vector_math.dart' show Vector2;
|
||||
|
||||
import '../common.dart';
|
||||
import '../utils/image.dart' as img;
|
||||
import '../utils/cursor_size.dart';
|
||||
import '../common/widgets/dialog.dart';
|
||||
import 'input_model.dart';
|
||||
import 'platform_model.dart';
|
||||
@@ -2864,8 +2863,6 @@ class CursorData {
|
||||
double hoty;
|
||||
final int width;
|
||||
final int height;
|
||||
double? localSize;
|
||||
late final int _visibleSize = cursorVisibleSize(image);
|
||||
|
||||
CursorData({
|
||||
required this.peerId,
|
||||
@@ -2882,34 +2879,9 @@ class CursorData {
|
||||
|
||||
int _doubleToInt(double v) => (v * 10e6).round().toInt();
|
||||
|
||||
bool get _usesLogicalCursorPixels => isLinux || isMacOS || isWeb;
|
||||
int get scaledWidth => _scaledDimension(width, scale);
|
||||
int get scaledHeight => _scaledDimension(height, scale);
|
||||
|
||||
int _scaledDimension(int dimension, double scale) {
|
||||
const minBitmapSize = 1;
|
||||
final pixels = dimension * scale;
|
||||
return _usesLogicalCursorPixels || localSize != null
|
||||
? max(minBitmapSize, pixels.round())
|
||||
: pixels.toInt();
|
||||
}
|
||||
|
||||
double _checkUpdateScale(double scale) {
|
||||
double oldScale = this.scale;
|
||||
if (localSize != null) {
|
||||
scale = _visibleSize == 0
|
||||
? 1.0
|
||||
: max(localSize!, kMinCursorSize) / _visibleSize;
|
||||
// Sparse peer artwork must not amplify the native bitmap allocation.
|
||||
const maxNativeCursorBitmapSize = 512;
|
||||
final maxScale = maxNativeCursorBitmapSize / max(width, height);
|
||||
if (scale > maxScale) {
|
||||
if (oldScale != maxScale) {
|
||||
debugPrint('Cursor $id exceeds the native bitmap limit; reducing scale');
|
||||
}
|
||||
scale = maxScale;
|
||||
}
|
||||
} else if (scale != 1.0) {
|
||||
if (scale != 1.0) {
|
||||
// Update data if scale changed.
|
||||
final tgtWidth = (width * scale).toInt();
|
||||
final tgtHeight = (height * scale).toInt();
|
||||
@@ -2920,17 +2892,13 @@ class CursorData {
|
||||
}
|
||||
}
|
||||
|
||||
const bytesPerPixel = 4;
|
||||
final byteLength = _scaledDimension(width, scale) *
|
||||
_scaledDimension(height, scale) * bytesPerPixel;
|
||||
if (_doubleToInt(oldScale) != _doubleToInt(scale) ||
|
||||
(isWindows && data != null && data!.length != byteLength)) {
|
||||
if (_doubleToInt(oldScale) != _doubleToInt(scale)) {
|
||||
if (isWindows) {
|
||||
data = img2
|
||||
.copyResize(
|
||||
image,
|
||||
width: _scaledDimension(width, scale),
|
||||
height: _scaledDimension(height, scale),
|
||||
width: (width * scale).toInt(),
|
||||
height: (height * scale).toInt(),
|
||||
interpolation: img2.Interpolation.average,
|
||||
)
|
||||
.getBytes(order: img2.ChannelOrder.bgra);
|
||||
@@ -2939,8 +2907,8 @@ class CursorData {
|
||||
img2.encodePng(
|
||||
img2.copyResize(
|
||||
image,
|
||||
width: _scaledDimension(width, scale),
|
||||
height: _scaledDimension(height, scale),
|
||||
width: (width * scale).toInt(),
|
||||
height: (height * scale).toInt(),
|
||||
interpolation: img2.Interpolation.average,
|
||||
),
|
||||
),
|
||||
@@ -2949,14 +2917,14 @@ class CursorData {
|
||||
}
|
||||
|
||||
this.scale = scale;
|
||||
hotx = hotxOrigin * scaledWidth / width;
|
||||
hoty = hotyOrigin * scaledHeight / height;
|
||||
hotx = hotxOrigin * scale;
|
||||
hoty = hotyOrigin * scale;
|
||||
return scale;
|
||||
}
|
||||
|
||||
String updateGetKey(double scale) {
|
||||
scale = _checkUpdateScale(scale);
|
||||
return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}${localSize == null ? '' : '_local'}';
|
||||
return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3470,8 +3438,6 @@ class CursorModel with ChangeNotifier {
|
||||
if (await _updateCache(rgba, image, id, hotx, hoty, width, height)) {
|
||||
_images[id]?.item1.dispose();
|
||||
_images[id] = Tuple3(image, hotx, hoty);
|
||||
} else {
|
||||
image.dispose();
|
||||
}
|
||||
|
||||
// Update last cursor data.
|
||||
@@ -3492,32 +3458,14 @@ class CursorModel with ChangeNotifier {
|
||||
img2.Image imgOrigin = img2.Image.fromBytes(
|
||||
width: w, height: h, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
|
||||
if (isWindows) {
|
||||
final pixels =
|
||||
await image.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
|
||||
if (pixels == null) {
|
||||
debugPrint('Could not read straight-alpha cursor pixels: $id');
|
||||
return false;
|
||||
}
|
||||
imgOrigin = img2.Image.fromBytes(
|
||||
width: w, height: h, bytes: pixels.buffer, order: img2.ChannelOrder.rgba);
|
||||
data = imgOrigin.getBytes(order: img2.ChannelOrder.bgra);
|
||||
} else {
|
||||
ByteData? imgBytes =
|
||||
await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (imgBytes == null) {
|
||||
debugPrint('Could not encode cursor PNG: $id');
|
||||
return false;
|
||||
}
|
||||
data = imgBytes.buffer.asUint8List();
|
||||
if (isLinux || isMacOS) {
|
||||
// Preserve the PNG's straight-alpha colors when resizing native cursors.
|
||||
final decoded = img2.decodePng(data);
|
||||
if (decoded == null) {
|
||||
debugPrint('Invalid native cursor PNG: $id');
|
||||
return false;
|
||||
}
|
||||
imgOrigin = decoded;
|
||||
}
|
||||
}
|
||||
final cache = CursorData(
|
||||
peerId: peerId,
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
import 'dart: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:image/image.dart' as img;
|
||||
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
|
||||
deleteCustomCursor(String key) =>
|
||||
custom_cursor_manager.CursorManager.instance.deleteCursor(key);
|
||||
resetSystemCursor() {}
|
||||
|
||||
double _nativeHotspot(double hotspot, int bitmapSize) {
|
||||
if (!isLinux && !isWindows) return hotspot;
|
||||
// GDK and Win32 take integer hotspots inside the bitmap; use the nearest pixel.
|
||||
return min(hotspot.round(), bitmapSize - 1).toDouble();
|
||||
}
|
||||
|
||||
Uint8List _padCursorWidth(Uint8List data, int width) {
|
||||
final bitmap = img.decodePng(data);
|
||||
if (bitmap == null) {
|
||||
throw const FormatException('Invalid native cursor PNG');
|
||||
}
|
||||
final padded = img.copyExpandCanvas(bitmap,
|
||||
newWidth: width,
|
||||
newHeight: bitmap.height,
|
||||
position: img.ExpandCanvasPosition.topLeft,
|
||||
toImage: img.Image(width: width, height: bitmap.height, numChannels: 4));
|
||||
return Uint8List.fromList(img.encodePng(padded));
|
||||
}
|
||||
|
||||
MouseCursor buildCursorOfCache(
|
||||
CursorModel cursor, double scale, CursorData? cache) {
|
||||
if (cache == null) {
|
||||
@@ -46,12 +23,6 @@ MouseCursor buildCursorOfCache(
|
||||
if (data == null) {
|
||||
return MouseCursor.defer;
|
||||
}
|
||||
// Pad tall Linux buffers to prevent clipping in the hardware cursor plane.
|
||||
final width = isLinux
|
||||
? max(cache.scaledWidth, cache.scaledHeight)
|
||||
: cache.scaledWidth;
|
||||
final buffer =
|
||||
width == cache.scaledWidth ? data : _padCursorWidth(data, width);
|
||||
debugPrint(
|
||||
"Register custom cursor with key $key (${cache.hotx},${cache.hoty})");
|
||||
// [Safety]
|
||||
@@ -61,11 +32,11 @@ MouseCursor buildCursorOfCache(
|
||||
custom_cursor_manager.CursorManager.instance
|
||||
.registerCursor(custom_cursor_manager.CursorData()
|
||||
..name = key
|
||||
..buffer = buffer
|
||||
..width = width
|
||||
..height = cache.scaledHeight
|
||||
..hotX = _nativeHotspot(cache.hotx, cache.scaledWidth)
|
||||
..hotY = _nativeHotspot(cache.hoty, cache.scaledHeight));
|
||||
..buffer = data
|
||||
..width = (cache.width * cache.scale).toInt()
|
||||
..height = (cache.height * cache.scale).toInt()
|
||||
..hotX = cache.hotx
|
||||
..hotY = cache.hoty);
|
||||
cursor.addKey(key);
|
||||
}
|
||||
return FlutterCustomMemoryImageCursor(key: key);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
int cursorVisibleSize(img.Image image) {
|
||||
var left = image.width;
|
||||
var top = image.height;
|
||||
var right = -1;
|
||||
var bottom = -1;
|
||||
for (final pixel in image) {
|
||||
if (pixel.a == 0) continue;
|
||||
if (pixel.x < left) left = pixel.x;
|
||||
if (pixel.x > right) right = pixel.x;
|
||||
if (pixel.y < top) top = pixel.y;
|
||||
if (pixel.y > bottom) bottom = pixel.y;
|
||||
}
|
||||
if (right < left) return 0;
|
||||
final width = right - left + 1;
|
||||
final height = bottom - top + 1;
|
||||
return width > height ? width : height;
|
||||
}
|
||||
|
||||
class LocalCursorSize extends ValueNotifier<double?> {
|
||||
LocalCursorSize() : super(null);
|
||||
|
||||
static const _channel = MethodChannel('org.rustdesk.rustdesk/cursor');
|
||||
double? _devicePixelRatio;
|
||||
int _generation = 0;
|
||||
|
||||
void ensureLoaded(double devicePixelRatio) {
|
||||
if (_devicePixelRatio == devicePixelRatio) return;
|
||||
_devicePixelRatio = devicePixelRatio;
|
||||
refresh();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final generation = ++_generation;
|
||||
try {
|
||||
final size = await _channel.invokeMethod<double>('getSystemCursorSize');
|
||||
if (size == null || !size.isFinite || size <= 0) {
|
||||
throw const FormatException('Invalid local system cursor size');
|
||||
}
|
||||
if (generation == _generation) value = size;
|
||||
} catch (error, stack) {
|
||||
FlutterError.reportError(FlutterErrorDetails(
|
||||
exception: error, stack: stack, library: 'local cursor size'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
++_generation;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -96,14 +96,12 @@ 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) {
|
||||
@@ -124,10 +122,8 @@ class ImagePainter extends CustomPainter {
|
||||
if (isWeb) {
|
||||
paint.filterQuality = FilterQuality.high;
|
||||
}
|
||||
final position = useIntegerPosition
|
||||
? Offset(x.toInt().toDouble(), y.toInt().toDouble())
|
||||
: Offset(x, y);
|
||||
canvas.drawImage(image!, position, paint);
|
||||
canvas.drawImage(
|
||||
image!, Offset(x.toInt().toDouble(), y.toInt().toDouble()), paint);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -52,8 +52,8 @@ class CursorManager {
|
||||
'cursor',
|
||||
jsonEncode({
|
||||
'url': cursorData.url,
|
||||
'hotx': cursorData.hotX.round(),
|
||||
'hoty': cursorData.hotY.round(),
|
||||
'hotx': cursorData.hotX.toInt(),
|
||||
'hoty': cursorData.hotY.toInt(),
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -116,8 +116,8 @@ MouseCursor buildCursorOfCache(
|
||||
CursorManager.instance.registerCursor(CursorData(
|
||||
key: key,
|
||||
url: 'data:image/rgba;base64,${base64Encode(data)}',
|
||||
width: cache.scaledWidth,
|
||||
height: cache.scaledHeight,
|
||||
width: (cache.width * cache.scale).toInt(),
|
||||
height: (cache.height * cache.scale).toInt(),
|
||||
hotX: cache.hotx,
|
||||
hotY: cache.hoty));
|
||||
cursor.addKey(key);
|
||||
|
||||
@@ -53,7 +53,6 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(XCURSOR REQUIRED IMPORTED_TARGET xcursor)
|
||||
|
||||
# Wayland protocol for keyboard shortcuts inhibit
|
||||
pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client)
|
||||
@@ -127,7 +126,6 @@ apply_standard_settings(${BINARY_NAME})
|
||||
# Add dependency libraries. Add any application-specific dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::XCURSOR)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${CMAKE_DL_LIBS})
|
||||
# target_link_libraries(${BINARY_NAME} PRIVATE librustdesk)
|
||||
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
#ifndef RUSTDESK_CURSOR_SIZE_H_
|
||||
#define RUSTDESK_CURSOR_SIZE_H_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#include <X11/Xcursor/Xcursor.h>
|
||||
#ifdef GDK_WINDOWING_WAYLAND
|
||||
#include <gdk/gdkwayland.h>
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace cursor_size {
|
||||
|
||||
template <typename IsVisible>
|
||||
double VisibleSize(int width, int height, IsVisible visible) {
|
||||
int left = width, top = height, right = -1, bottom = -1;
|
||||
for (int y = 0; y < height; ++y) {
|
||||
for (int x = 0; x < width; ++x) {
|
||||
if (!visible(x, y)) continue;
|
||||
left = std::min(left, x);
|
||||
right = std::max(right, x);
|
||||
top = std::min(top, y);
|
||||
bottom = std::max(bottom, y);
|
||||
}
|
||||
}
|
||||
if (right < left) throw std::runtime_error("System cursor has no visible pixels");
|
||||
return std::max(right - left + 1, bottom - top + 1);
|
||||
}
|
||||
|
||||
inline double WaylandSize(GtkWidget* view) {
|
||||
g_autofree gchar* theme = nullptr;
|
||||
gint size = 0;
|
||||
g_object_get(gtk_widget_get_settings(view), "gtk-cursor-theme-name", &theme,
|
||||
"gtk-cursor-theme-size", &size, nullptr);
|
||||
// GTK uses 24 when the theme size is unset, and loads at the window scale.
|
||||
constexpr int kDefaultThemeSize = 24;
|
||||
if (size == 0) size = kDefaultThemeSize;
|
||||
const int scale = gtk_widget_get_scale_factor(view);
|
||||
if (size < 0 || scale <= 0 || size > G_MAXINT / scale) {
|
||||
throw std::runtime_error("Invalid GTK cursor theme size or window scale");
|
||||
}
|
||||
auto* loaded = XcursorLibraryLoadImage("default", theme, size * scale);
|
||||
// Match GTK's CSS default -> traditional left_ptr name mapping.
|
||||
if (!loaded) loaded = XcursorLibraryLoadImage("left_ptr", theme, size * scale);
|
||||
std::unique_ptr<XcursorImage, decltype(&XcursorImageDestroy)> image(
|
||||
loaded, XcursorImageDestroy);
|
||||
if (!image) throw std::runtime_error("Could not load the GTK system cursor");
|
||||
// GTK reduces the buffer scale until both theme dimensions are divisible.
|
||||
// For example, a 64px theme at 3x is displayed using a buffer scale of 2.
|
||||
int cursor_scale = scale;
|
||||
while (image->width % cursor_scale != 0 || image->height % cursor_scale != 0) {
|
||||
--cursor_scale;
|
||||
}
|
||||
constexpr XcursorPixel kAlphaMask = 0xff000000;
|
||||
return VisibleSize(image->width, image->height, [&](int x, int y) {
|
||||
return (image->pixels[y * image->width + x] & kAlphaMask) != 0;
|
||||
}) / cursor_scale;
|
||||
}
|
||||
|
||||
inline double SystemSize(GtkWidget* view) {
|
||||
GdkDisplay* display = gtk_widget_get_display(view);
|
||||
#ifdef GDK_WINDOWING_WAYLAND
|
||||
if (GDK_IS_WAYLAND_DISPLAY(display)) return WaylandSize(view);
|
||||
#endif
|
||||
g_autoptr(GdkCursor) cursor = gdk_cursor_new_from_name(display, "default");
|
||||
if (!cursor) throw std::runtime_error("Could not load the GDK system cursor");
|
||||
g_autoptr(GdkPixbuf) image = gdk_cursor_get_image(cursor);
|
||||
if (!image) throw std::runtime_error("Could not read the GDK system cursor");
|
||||
const auto* pixels = gdk_pixbuf_read_pixels(image);
|
||||
const int stride = gdk_pixbuf_get_rowstride(image);
|
||||
const int channels = gdk_pixbuf_get_n_channels(image);
|
||||
return VisibleSize(gdk_pixbuf_get_width(image), gdk_pixbuf_get_height(image),
|
||||
[&](int x, int y) {
|
||||
return !gdk_pixbuf_get_has_alpha(image) ||
|
||||
pixels[y * stride + x * channels + channels - 1] != 0;
|
||||
});
|
||||
}
|
||||
|
||||
inline void Register(FlView* view) {
|
||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||
FlMethodChannel* channel = fl_method_channel_new(
|
||||
fl_engine_get_binary_messenger(fl_view_get_engine(view)),
|
||||
"org.rustdesk.rustdesk/cursor", FL_METHOD_CODEC(codec));
|
||||
fl_method_channel_set_method_call_handler(channel,
|
||||
[](FlMethodChannel*, FlMethodCall* call, gpointer data) {
|
||||
g_autoptr(GError) error = nullptr;
|
||||
if (g_strcmp0(fl_method_call_get_name(call), "getSystemCursorSize") != 0) {
|
||||
fl_method_call_respond_not_implemented(call, &error);
|
||||
} else {
|
||||
try {
|
||||
g_autoptr(FlValue) value = fl_value_new_float(SystemSize(GTK_WIDGET(data)));
|
||||
fl_method_call_respond_success(call, value, &error);
|
||||
} catch (const std::exception& failure) {
|
||||
fl_method_call_respond_error(call, "cursor_size", failure.what(), nullptr, &error);
|
||||
}
|
||||
}
|
||||
if (error) g_warning("Cursor size response failed: %s", error->message);
|
||||
}, view, nullptr);
|
||||
g_object_set_data_full(G_OBJECT(view), "cursor-size-channel", channel, [](gpointer data) {
|
||||
fl_method_channel_set_method_call_handler(FL_METHOD_CHANNEL(data), nullptr, nullptr, nullptr);
|
||||
g_object_unref(data);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace cursor_size
|
||||
|
||||
#endif // RUSTDESK_CURSOR_SIZE_H_
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include "bump_mouse.h"
|
||||
#include "cursor_size.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
@@ -95,7 +94,6 @@ static void on_subwindow_created(FlPluginRegistry* registry) {
|
||||
// Set up side button forwarding for sub-windows.
|
||||
if (registry == NULL || !FL_IS_VIEW(registry)) return;
|
||||
FlView* view = FL_VIEW(registry);
|
||||
cursor_size::Register(view);
|
||||
GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(view));
|
||||
if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) {
|
||||
FlMethodChannel* channel = side_buttons_create_channel(fl_view_get_engine(view));
|
||||
@@ -180,7 +178,6 @@ static void my_application_activate(GApplication* application) {
|
||||
(WindowCreatedCallback)on_subwindow_created);
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
cursor_size::Register(view);
|
||||
|
||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||
self->host_channel = fl_method_channel_new(
|
||||
|
||||
@@ -198,46 +198,7 @@ class MainFlutterWindow: NSWindow {
|
||||
}
|
||||
}
|
||||
|
||||
private func registerCursorSizeChannel(registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/cursor", binaryMessenger: registrar.messenger)
|
||||
channel.setMethodCallHandler { call, result in
|
||||
guard call.method == "getSystemCursorSize" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
guard let size = self.systemCursorSize() else {
|
||||
result(FlutterError(code: "cursor_size", message: "Could not measure the local system cursor", details: nil))
|
||||
return
|
||||
}
|
||||
result(size)
|
||||
}
|
||||
}
|
||||
|
||||
private func systemCursorSize() -> Double? {
|
||||
let image = NSCursor.arrow.image
|
||||
guard let data = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: data),
|
||||
bitmap.pixelsWide > 0, bitmap.pixelsHigh > 0 else { return nil }
|
||||
var left = bitmap.pixelsWide, top = bitmap.pixelsHigh
|
||||
var right = -1, bottom = -1
|
||||
for y in 0..<bitmap.pixelsHigh {
|
||||
for x in 0..<bitmap.pixelsWide {
|
||||
guard let color = bitmap.colorAt(x: x, y: y) else { return nil }
|
||||
if color.alphaComponent == 0 { continue }
|
||||
left = min(left, x)
|
||||
right = max(right, x)
|
||||
top = min(top, y)
|
||||
bottom = max(bottom, y)
|
||||
}
|
||||
}
|
||||
guard right >= left else { return nil }
|
||||
let width = Double(right - left + 1) * image.size.width / Double(bitmap.pixelsWide)
|
||||
let height = Double(bottom - top + 1) * image.size.height / Double(bitmap.pixelsHigh)
|
||||
return max(width, height)
|
||||
}
|
||||
|
||||
public func setMethodHandler(registrar: FlutterPluginRegistrar) {
|
||||
registerCursorSizeChannel(registrar: registrar)
|
||||
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/host", binaryMessenger: registrar.messenger)
|
||||
channel.setMethodCallHandler({
|
||||
(call, result) -> Void in
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.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;
|
||||
|
||||
// Source size, source hotspot, requested/effective scale, artwork size, integer hotspot.
|
||||
final _cases = [
|
||||
((17, 23), (4.0, 4.0), 1 / 3, 12 / 17, (12, 16), (3.0, 3.0)),
|
||||
((32, 16), (16.0, 8.0), 0.5, 0.75, (24, 12), (12.0, 6.0)),
|
||||
((34, 46), (8.0, 8.0), 0.5, 0.5, (17, 23), (4.0, 4.0)),
|
||||
((24, 24), (11.0, 11.0), 1 / 3, 0.5, (12, 12), (6.0, 6.0)),
|
||||
((1, 48), (0.0, 24.0), 1.0, 1.0, (1, 48), (0.0, 24.0)),
|
||||
((9, 18), (4.0, 9.0), 1.0, 1.0, (9, 18), (4.0, 9.0)),
|
||||
((24, 24), (4.0, 4.0), 0.1, 0.5, (12, 12), (2.0, 2.0)),
|
||||
(
|
||||
(19, 27),
|
||||
(6.0, 13.0),
|
||||
1.37,
|
||||
1.37,
|
||||
Platform.isWindows ? (26, 36) : (26, 37),
|
||||
Platform.isWindows ? (8.0, 17.0) : (8.0, 18.0)
|
||||
),
|
||||
(
|
||||
(18, 36),
|
||||
(8.0, 18.0),
|
||||
0.75,
|
||||
0.75,
|
||||
Platform.isWindows ? (13, 27) : (14, 27),
|
||||
(6.0, 14.0)
|
||||
),
|
||||
];
|
||||
const _hotspotTolerance = 1e-9;
|
||||
|
||||
class _CursorModel implements CursorModel {
|
||||
@override
|
||||
final Set<String> cachedKeys = {};
|
||||
|
||||
@override
|
||||
void addKey(String key) => cachedKeys.add(key);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _CursorFFI implements FFI {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
CursorData _cursorData((int, int) size, (double, double) hotspot) {
|
||||
final image = img.Image(width: size.$1, height: size.$2, numChannels: 4);
|
||||
for (var y = 0; y < image.height; y++) {
|
||||
image.setPixelRgba(image.width ~/ 2, y, 255, 255, 255, 255);
|
||||
}
|
||||
for (var x = 0; x < image.width; x++) {
|
||||
image.setPixelRgba(x, 0, 255, 0, 0, 255);
|
||||
image.setPixelRgba(x, image.height - 1, 0, 0, 255, 255);
|
||||
}
|
||||
return CursorData(
|
||||
peerId: 'cursor-test',
|
||||
id: 'native',
|
||||
image: image,
|
||||
scale: 1,
|
||||
data: Platform.isWindows
|
||||
? image.getBytes(order: img.ChannelOrder.bgra)
|
||||
: Uint8List.fromList(img.encodePng(image)),
|
||||
hotxOrigin: hotspot.$1,
|
||||
hotyOrigin: hotspot.$2,
|
||||
width: size.$1,
|
||||
height: size.$2,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<dynamic, dynamic>> _register(
|
||||
WidgetTester tester, CursorData data, double scale) async {
|
||||
final channel = Platform.isWindows
|
||||
? SystemChannels.mouseCursor
|
||||
: const MethodChannel('flutter_custom_cursor');
|
||||
Map<dynamic, dynamic>? registered;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||
(call) async {
|
||||
expect(
|
||||
call.method,
|
||||
Platform.isWindows
|
||||
? 'createCustomCursor/windows'
|
||||
: 'createCustomCursor');
|
||||
registered = call.arguments as Map<dynamic, dynamic>;
|
||||
return registered!['name'];
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null));
|
||||
buildCursorOfCache(_CursorModel(), scale, data);
|
||||
await tester.pump();
|
||||
expect(registered, isNotNull);
|
||||
return registered!;
|
||||
}
|
||||
|
||||
void _expectArtwork(img.Image native, img.Image artwork) {
|
||||
final content = img.copyCrop(native,
|
||||
x: 0, y: 0, width: artwork.width, height: artwork.height);
|
||||
expect(content.getBytes(), artwork.getBytes());
|
||||
for (final pixel in native) {
|
||||
if (pixel.x >= artwork.width) {
|
||||
expect(pixel.a, 0, reason: 'Cursor padding must be transparent');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('received cursor keeps edge colors across scale changes',
|
||||
(tester) async {
|
||||
const side = 4;
|
||||
for (final (rgba, expected) in [
|
||||
([128, 64, 32, 128], [255, 128, 64, 128]),
|
||||
([0, 0, 0, 0], [0, 0, 0, 0]),
|
||||
([32, 64, 128, 255], [32, 64, 128, 255]),
|
||||
]) {
|
||||
final cursor = CursorModel(WeakReference<FFI>(_CursorFFI()))
|
||||
..id = 'edge-colors';
|
||||
addTearDown(cursor.disposeImages);
|
||||
addTearDown(cursor.dispose);
|
||||
await tester.runAsync(() => cursor.updateCursorData({
|
||||
'id': 'edge-colors',
|
||||
'hotx': '1',
|
||||
'hoty': '1',
|
||||
'width': '$side',
|
||||
'height': '$side',
|
||||
'colors': jsonEncode(List.generate(side * side, (_) => rgba)
|
||||
.expand((pixel) => pixel)
|
||||
.toList()),
|
||||
}));
|
||||
final data = cursor.cache!;
|
||||
for (final scale in [1.0, 0.5, 1.0]) {
|
||||
data.updateGetKey(scale);
|
||||
final image = Platform.isWindows
|
||||
? img.Image.fromBytes(
|
||||
width: data.scaledWidth,
|
||||
height: data.scaledHeight,
|
||||
bytes: data.data!.buffer,
|
||||
order: img.ChannelOrder.bgra)
|
||||
: img.decodePng(data.data!)!;
|
||||
for (final pixel in image) {
|
||||
expect([pixel.r, pixel.g, pixel.b, pixel.a], expected,
|
||||
reason: 'Edge color must survive scale $scale');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (final (source, hotspot, scale, effectiveScale, size, integerHotspot)
|
||||
in _cases) {
|
||||
testWidgets('${source.$1}x${source.$2} cursor at scale $scale',
|
||||
(tester) async {
|
||||
final data = _cursorData(source, hotspot);
|
||||
final cursor = await _register(tester, data, scale);
|
||||
final artwork = Platform.isWindows
|
||||
? img.Image.fromBytes(
|
||||
width: data.scaledWidth,
|
||||
height: data.scaledHeight,
|
||||
bytes: data.data!.buffer,
|
||||
order: img.ChannelOrder.bgra)
|
||||
: img.decodePng(data.data!)!;
|
||||
final native = Platform.isWindows
|
||||
? img.Image.fromBytes(
|
||||
width: cursor['width'],
|
||||
height: cursor['height'],
|
||||
bytes: (cursor['buffer'] as Uint8List).buffer,
|
||||
bytesOffset: (cursor['buffer'] as Uint8List).offsetInBytes,
|
||||
order: img.ChannelOrder.bgra)
|
||||
: img.decodePng(cursor['buffer'] as Uint8List)!;
|
||||
final width = Platform.isLinux && size.$2 > size.$1 ? size.$2 : size.$1;
|
||||
|
||||
expect(data.scale, effectiveScale);
|
||||
expect((artwork.width, artwork.height), size);
|
||||
expect(data.hotx / size.$1,
|
||||
closeTo(hotspot.$1 / source.$1, _hotspotTolerance));
|
||||
expect(data.hoty / size.$2,
|
||||
closeTo(hotspot.$2 / source.$2, _hotspotTolerance));
|
||||
expect((native.width, native.height), (width, size.$2));
|
||||
expect((cursor['width'], cursor['height']), (width, size.$2));
|
||||
expect(
|
||||
(cursor['hotX'], cursor['hotY']),
|
||||
Platform.isLinux || Platform.isWindows
|
||||
? integerHotspot
|
||||
: (data.hotx, data.hoty));
|
||||
_expectArtwork(native, artwork);
|
||||
if (width == artwork.width) {
|
||||
expect(cursor['buffer'], data.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
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';
|
||||
import 'package:flutter_hbb/utils/cursor_size.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
|
||||
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 ChangeNotifier implements CursorModel {
|
||||
_Cursor(this.cache);
|
||||
|
||||
@override
|
||||
CursorData cache;
|
||||
@override
|
||||
ui.Image? get image => null;
|
||||
@override
|
||||
double get hotx => cache.hotxOrigin;
|
||||
@override
|
||||
double get hoty => cache.hotyOrigin;
|
||||
@override
|
||||
final Set<String> cachedKeys = {};
|
||||
@override
|
||||
void addKey(String key) => cachedKeys.add(key);
|
||||
@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 get isPeerLinux => false;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
@override
|
||||
final inputModel = _Input();
|
||||
@override
|
||||
final ffiModel = _Peer();
|
||||
}
|
||||
|
||||
CursorData _cursor(img.Image image,
|
||||
{String id = 'sprite', double hotspot = 0}) {
|
||||
return CursorData(
|
||||
peerId: 'local-size-test',
|
||||
id: id,
|
||||
image: image,
|
||||
scale: 1,
|
||||
data: Platform.isWindows
|
||||
? image.getBytes(order: img.ChannelOrder.bgra)
|
||||
: Uint8List.fromList(img.encodePng(image)),
|
||||
hotxOrigin: hotspot,
|
||||
hotyOrigin: hotspot,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
);
|
||||
}
|
||||
|
||||
CursorData _arrow(int density) {
|
||||
final image =
|
||||
img.Image(width: 17 * density, height: 23 * density, numChannels: 4);
|
||||
img.fill(image, color: img.ColorRgba8(255, 255, 255, 255));
|
||||
return _cursor(image, id: 'arrow-$density', hotspot: 4.0 * density);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('local size preserves remote pixels, padding and asymmetric hotspot',
|
||||
() {
|
||||
final artwork = img.Image(width: 40, height: 48, numChannels: 4);
|
||||
img.fillRect(artwork,
|
||||
x1: 8,
|
||||
y1: 12,
|
||||
x2: 22,
|
||||
y2: 35,
|
||||
color: img.ColorRgba8(180, 90, 45, 128),
|
||||
alphaBlend: false);
|
||||
for (final density in [1, 2, 3]) {
|
||||
final source = img.copyResize(artwork,
|
||||
width: artwork.width * density,
|
||||
height: artwork.height * density,
|
||||
interpolation: img.Interpolation.nearest);
|
||||
expect(cursorVisibleSize(source), 24 * density);
|
||||
final cursor = CursorData(
|
||||
peerId: 'local-size-test',
|
||||
id: 'padded-$density',
|
||||
image: source,
|
||||
scale: 1,
|
||||
data: Platform.isWindows
|
||||
? source.getBytes(order: img.ChannelOrder.bgra)
|
||||
: Uint8List.fromList(img.encodePng(source)),
|
||||
hotxOrigin: 10.0 * density,
|
||||
hotyOrigin: 17.0 * density,
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
)..localSize = 24;
|
||||
cursor.updateGetKey(0.01);
|
||||
final output = Platform.isWindows
|
||||
? img.Image.fromBytes(
|
||||
width: cursor.scaledWidth,
|
||||
height: cursor.scaledHeight,
|
||||
bytes: cursor.data!.buffer,
|
||||
order: img.ChannelOrder.bgra)
|
||||
: img.decodePng(cursor.data!)!;
|
||||
expect(cursorVisibleSize(output), 24);
|
||||
expect(output.getBytes(), artwork.getBytes());
|
||||
expect((cursor.hotx, cursor.hoty), (10, 17));
|
||||
}
|
||||
});
|
||||
|
||||
test('local cursor minimum keeps a thin remote cursor visible', () {
|
||||
final thin = img.Image(width: 1, height: 48, numChannels: 4);
|
||||
img.fill(thin, color: img.ColorRgba8(255, 255, 255, 255));
|
||||
final cursor = _cursor(thin)..localSize = 2;
|
||||
cursor.updateGetKey(1);
|
||||
expect(cursor.scaledWidth, 1);
|
||||
expect(cursor.scaledHeight, kMinCursorSize);
|
||||
final blank = _cursor(img.Image(width: 17, height: 23, numChannels: 4))
|
||||
..localSize = 24;
|
||||
blank.updateGetKey(1);
|
||||
final output = Platform.isWindows
|
||||
? img.Image.fromBytes(
|
||||
width: 17,
|
||||
height: 23,
|
||||
bytes: blank.data!.buffer,
|
||||
order: img.ChannelOrder.bgra)
|
||||
: img.decodePng(blank.data!)!;
|
||||
expect(cursorVisibleSize(output), 0);
|
||||
expect(blank.scale, 1);
|
||||
});
|
||||
|
||||
test('switching local sizing keeps bitmap data and cache geometry consistent',
|
||||
() {
|
||||
final pending = _arrow(1)..data = null;
|
||||
pending.updateGetKey(1);
|
||||
expect(pending.data, isNull);
|
||||
final cursor = _arrow(1);
|
||||
const scale = 1.5;
|
||||
final originalKey = cursor.updateGetKey(scale);
|
||||
cursor.localSize = 23 * scale;
|
||||
final localKey = cursor.updateGetKey(scale);
|
||||
expect(cursor.scaledWidth, 26);
|
||||
if (Platform.isWindows) {
|
||||
expect(localKey, isNot(originalKey));
|
||||
expect(cursor.data!.length, 26 * 35 * 4);
|
||||
}
|
||||
cursor.localSize = null;
|
||||
expect(cursor.updateGetKey(scale), originalKey);
|
||||
if (Platform.isWindows) expect(cursor.data!.length, 25 * 34 * 4);
|
||||
});
|
||||
|
||||
for (final (dpr, style, scale) in [
|
||||
for (final dpr in [1.0, 2.0, 3.0])
|
||||
for (final (style, scale) in [
|
||||
(kRemoteViewStyleAdaptive, 0.5),
|
||||
(kRemoteViewStyleCustom, 0.25),
|
||||
(kRemoteViewStyleCustom, 2.0),
|
||||
]) (dpr, style, scale),
|
||||
]) {
|
||||
testWidgets('$style scale=$scale DPR=$dpr preserves local size and hotspot',
|
||||
(tester) async {
|
||||
final channel = Platform.isWindows
|
||||
? SystemChannels.mouseCursor
|
||||
: const MethodChannel('flutter_custom_cursor');
|
||||
final registered = <Map<dynamic, dynamic>>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||
(call) async {
|
||||
if (call.method.startsWith('createCustomCursor')) {
|
||||
final arguments = call.arguments as Map<dynamic, dynamic>;
|
||||
registered.add(arguments);
|
||||
return arguments['name'];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null));
|
||||
const sizeChannel = MethodChannel('org.rustdesk.rustdesk/cursor');
|
||||
tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(sizeChannel, (call) async {
|
||||
expect(call.method, 'getSystemCursorSize');
|
||||
return Platform.isWindows ? 23.0 * dpr : 23.0;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(sizeChannel, null));
|
||||
final cursor = _Cursor(_arrow(1));
|
||||
final zoom = false.obs;
|
||||
await tester.pumpWidget(MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>(create: (_) => _Image()),
|
||||
ChangeNotifierProvider<CanvasModel>(
|
||||
create: (_) => _Canvas(dpr, style: style, scale: scale)),
|
||||
ChangeNotifierProvider<CursorModel>.value(value: cursor),
|
||||
],
|
||||
child: ImagePaint(
|
||||
ffi: _FFI(),
|
||||
id: 'local-size-test',
|
||||
zoomCursor: zoom,
|
||||
cursorOverImage: true.obs,
|
||||
keyboardEnabled: true.obs,
|
||||
remoteCursorMoved: false.obs,
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
expect(registered, isNotEmpty);
|
||||
expect(cursor.cache.localSize, Platform.isWindows ? 23.0 * dpr : 23.0);
|
||||
final original = registered.last;
|
||||
final beforeDensityChange = registered.length;
|
||||
cursor.cache = _arrow(2);
|
||||
cursor.notifyListeners();
|
||||
await tester.pump();
|
||||
expect(registered, hasLength(beforeDensityChange + 1));
|
||||
final retina = registered.last;
|
||||
expect(
|
||||
[retina['width'], retina['height'], retina['hotX'], retina['hotY']],
|
||||
[
|
||||
original['width'],
|
||||
original['height'],
|
||||
original['hotX'],
|
||||
original['hotY']
|
||||
],
|
||||
reason: 'Only the remote raster density changed; the local cursor must '
|
||||
'retain its size and hotspot.',
|
||||
);
|
||||
zoom.value = true;
|
||||
await tester.pump();
|
||||
expect(cursor.cache.localSize, isNull);
|
||||
final zoomScale = scale * (Platform.isWindows ? dpr : 1);
|
||||
expect(cursor.cache.scale,
|
||||
zoomScale < kMinCursorSize / 34 ? kMinCursorSize / 34 : zoomScale);
|
||||
expect(cursor.cache.hotx / cursor.cache.scaledWidth, closeTo(4 / 17, 1e-9));
|
||||
expect(cursor.cache.hoty / cursor.cache.scaledHeight, closeTo(4 / 23, 1e-9));
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
cursor.dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
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';
|
||||
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
|
||||
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 ChangeNotifier implements CursorModel {
|
||||
_Cursor(this.cache);
|
||||
|
||||
@override
|
||||
CursorData cache;
|
||||
@override
|
||||
ui.Image? get image => null;
|
||||
@override
|
||||
double get hotx => cache.hotxOrigin;
|
||||
@override
|
||||
double get hoty => cache.hotyOrigin;
|
||||
@override
|
||||
final Set<String> cachedKeys = {};
|
||||
@override
|
||||
void addKey(String key) => cachedKeys.add(key);
|
||||
@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 get isPeerLinux => false;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
@override
|
||||
final inputModel = _Input();
|
||||
@override
|
||||
final ffiModel = _Peer();
|
||||
}
|
||||
|
||||
CursorData _cursor(img.Image image,
|
||||
{String id = 'sprite', double hotspot = 0}) {
|
||||
return CursorData(
|
||||
peerId: 'local-size-test',
|
||||
id: id,
|
||||
image: image,
|
||||
scale: 1,
|
||||
data: Platform.isWindows
|
||||
? image.getBytes(order: img.ChannelOrder.bgra)
|
||||
: Uint8List.fromList(img.encodePng(image)),
|
||||
hotxOrigin: hotspot,
|
||||
hotyOrigin: hotspot,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
);
|
||||
}
|
||||
|
||||
CursorData _arrow(int density) {
|
||||
final image =
|
||||
img.Image(width: 17 * density, height: 23 * density, numChannels: 4);
|
||||
img.fill(image, color: img.ColorRgba8(255, 255, 255, 255));
|
||||
return _cursor(image, id: 'arrow-$density', hotspot: 4.0 * density);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('pending or failed size measurement preserves the remote cursor',
|
||||
(tester) async {
|
||||
final channel = Platform.isWindows
|
||||
? SystemChannels.mouseCursor
|
||||
: const MethodChannel('flutter_custom_cursor');
|
||||
final registered = <Map<dynamic, dynamic>>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||
(call) async {
|
||||
if (call.method.startsWith('createCustomCursor')) {
|
||||
final arguments = call.arguments as Map<dynamic, dynamic>;
|
||||
registered.add(arguments);
|
||||
return arguments['name'];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null));
|
||||
const sizeChannel = MethodChannel('org.rustdesk.rustdesk/cursor');
|
||||
final measurement = Completer<double>();
|
||||
tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(sizeChannel, (_) => measurement.future);
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(sizeChannel, null));
|
||||
final cursor = _Cursor(_arrow(2));
|
||||
await tester.pumpWidget(_view(cursor));
|
||||
expect(registered, isNotEmpty);
|
||||
measurement.completeError(PlatformException(code: 'cursor_size'));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isA<PlatformException>());
|
||||
expect(cursor.cache.localSize, isNull);
|
||||
final scale = Platform.isWindows ? 1.0 : 0.5;
|
||||
expect(cursor.cache.scaledWidth, 34 * scale);
|
||||
expect(cursor.cache.scaledHeight, 46 * scale);
|
||||
expect(cursor.cache.hotx, 8 * scale);
|
||||
expect(cursor.cache.hoty, 8 * scale);
|
||||
expect(registered.last['hotX'], 8 * scale);
|
||||
expect(registered.last['hotY'], 8 * scale);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
cursor.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Widget _view(_Cursor cursor) => MediaQuery(
|
||||
data: const MediaQueryData(devicePixelRatio: 2),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>(create: (_) => _Image()),
|
||||
ChangeNotifierProvider<CanvasModel>(create: (_) => _Canvas(2,
|
||||
style: kRemoteViewStyleAdaptive, scale: 0.25)),
|
||||
ChangeNotifierProvider<CursorModel>.value(value: cursor),
|
||||
],
|
||||
child: ImagePaint(
|
||||
ffi: _FFI(),
|
||||
id: 'measurement-failure-test',
|
||||
zoomCursor: false.obs,
|
||||
cursorOverImage: true.obs,
|
||||
keyboardEnabled: true.obs,
|
||||
remoteCursorMoved: false.obs,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1,187 +0,0 @@
|
||||
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/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);
|
||||
|
||||
@override
|
||||
final ui.Image image;
|
||||
@override
|
||||
double get hotx => _hotspot.dx;
|
||||
@override
|
||||
double get hoty => _hotspot.dy;
|
||||
@override
|
||||
double get x => _remotePosition.dx;
|
||||
@override
|
||||
double get y => _remotePosition.dy;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _ImageModel extends Fake implements ImageModel {
|
||||
_ImageModel(this.useTextureRender);
|
||||
|
||||
@override
|
||||
final bool useTextureRender;
|
||||
}
|
||||
|
||||
class _Peer extends Fake implements FfiModel {
|
||||
@override
|
||||
final pi = PeerInfo();
|
||||
@override
|
||||
bool get isPeerLinux => false;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
_FFI(bool useTexture) : imageModel = _ImageModel(useTexture);
|
||||
|
||||
@override
|
||||
final ImageModel imageModel;
|
||||
@override
|
||||
final ffiModel = _Peer();
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
|
||||
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
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final (style, zoom, dpr, source, canvasScale, scale, texture) in [
|
||||
(kRemoteViewStyleAdaptive, false, 2.0, (48, 64), 0.375, 0.375, true),
|
||||
(kRemoteViewStyleAdaptive, false, 3.0, (48, 64), 0.25, 0.25, true),
|
||||
(kRemoteViewStyleAdaptive, true, 3.0, (48, 64), 0.375, 0.375, true),
|
||||
(kRemoteViewStyleOriginal, false, 2.0, (48, 64), 0.5, 0.5, true),
|
||||
(kRemoteViewStyleOriginal, true, 2.0, (48, 64), 0.5, 0.5, true),
|
||||
for (final zoom in [false, true])
|
||||
for (final scale in [0.25, 2.0])
|
||||
for (final texture in [false, true])
|
||||
(kRemoteViewStyleCustom, zoom, 2.0, (48, 64), scale, scale, texture),
|
||||
(
|
||||
kRemoteViewStyleAdaptive,
|
||||
false,
|
||||
2.0,
|
||||
(9, 18),
|
||||
0.1,
|
||||
Platform.isWindows ? 2 / 3 : 4 / 3,
|
||||
true
|
||||
),
|
||||
(
|
||||
kRemoteViewStyleAdaptive,
|
||||
true,
|
||||
2.0,
|
||||
(9, 18),
|
||||
0.1,
|
||||
Platform.isWindows ? 2 / 3 : 4 / 3,
|
||||
true
|
||||
),
|
||||
(kRemoteViewStyleAdaptive, false, 2.25, (48, 48), 0.375, 0.375, false),
|
||||
(
|
||||
kRemoteViewStyleAdaptive,
|
||||
true,
|
||||
2.0,
|
||||
(9, 18),
|
||||
0.1,
|
||||
Platform.isWindows ? 2 / 3 : 4 / 3,
|
||||
false
|
||||
),
|
||||
]) {
|
||||
testWidgets(
|
||||
'$style zoom=$zoom dpr=$dpr source=$source texture=$texture keeps remote geometry',
|
||||
(tester) async {
|
||||
final image = (await tester.runAsync(
|
||||
() => createTestImage(width: source.$1, height: source.$2)))!;
|
||||
addTearDown(image.dispose);
|
||||
await tester.pumpWidget(MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<CursorModel>(
|
||||
create: (_) => _CursorModel(image)),
|
||||
ChangeNotifierProvider<CanvasModel>(
|
||||
create: (_) => _CanvasModel(style, canvasScale, texture)),
|
||||
],
|
||||
child: CursorPaint(id: 'cursor-test', zoomCursor: zoom.obs),
|
||||
),
|
||||
));
|
||||
final painter = tester
|
||||
.widget<CustomPaint>(find.byType(CustomPaint))
|
||||
.painter! as ImagePainter;
|
||||
|
||||
expect(painter.image, same(image));
|
||||
expect(painter.scale, scale);
|
||||
var imageOrigin = _canvasOffset;
|
||||
if (!texture) {
|
||||
final background = _Canvas();
|
||||
ImagePainter(
|
||||
image: image,
|
||||
x: _canvasOffset.dx / canvasScale,
|
||||
y: _canvasOffset.dy / canvasScale,
|
||||
scale: canvasScale,
|
||||
).paint(background, _viewport);
|
||||
imageOrigin = background.position!;
|
||||
}
|
||||
final target = _remotePosition * canvasScale + imageOrigin;
|
||||
expect((Offset(painter.x, painter.y) + _hotspot) * scale, target);
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('sparse remote artwork cannot amplify the native bitmap without limit',
|
||||
() {
|
||||
const sourceSize = 64;
|
||||
const bitmapLimit = 512;
|
||||
final image = img.Image(width: sourceSize, height: sourceSize, numChannels: 4)
|
||||
..setPixelRgba(0, 0, 255, 255, 255, 255);
|
||||
final cache = CursorData(
|
||||
peerId: 'sparse-cursor',
|
||||
id: '1',
|
||||
image: image,
|
||||
scale: 1,
|
||||
data: Uint8List.fromList(img.encodePng(image)),
|
||||
hotxOrigin: 32,
|
||||
hotyOrigin: 48,
|
||||
width: sourceSize,
|
||||
height: sourceSize,
|
||||
)..localSize = sourceSize.toDouble();
|
||||
final messages = <String>[];
|
||||
final previous = debugPrint;
|
||||
debugPrint = (String? message, {int? wrapWidth}) {
|
||||
messages.add(message ?? '');
|
||||
};
|
||||
addTearDown(() => debugPrint = previous);
|
||||
|
||||
cache.updateGetKey(1);
|
||||
|
||||
expect(max(cache.scaledWidth, cache.scaledHeight), bitmapLimit);
|
||||
expect(cache.hotx, 32 * cache.scaledWidth / sourceSize);
|
||||
expect(cache.hoty, 48 * cache.scaledHeight / sourceSize);
|
||||
expect(messages.single, contains('bitmap limit'));
|
||||
});
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
@TestOn('browser')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:js' as js;
|
||||
import 'dart:typed_data';
|
||||
|
||||
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:image/image.dart' as img;
|
||||
|
||||
class _CursorModel extends Fake implements model.CursorModel {
|
||||
@override
|
||||
final Set<String> cachedKeys = {};
|
||||
|
||||
@override
|
||||
void addKey(String key) => cachedKeys.add(key);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('Web cursor rounds its bitmap and hotspot together', () async {
|
||||
Map<String, dynamic>? registered;
|
||||
final original = js.context['setByName'];
|
||||
js.context['setByName'] = js.allowInterop((String name, String value) {
|
||||
expect(name, 'cursor');
|
||||
registered = jsonDecode(value) as Map<String, dynamic>;
|
||||
});
|
||||
addTearDown(() => js.context['setByName'] = original);
|
||||
|
||||
const sourceSide = 48;
|
||||
final image = img.Image(width: sourceSide, height: sourceSide, numChannels: 4);
|
||||
img.fill(image, color: img.ColorRgba8(255, 255, 255, 255));
|
||||
for (final (sourceHotspot, scale, outputSide, expectedHotspot) 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 cache = model.CursorData(
|
||||
peerId: 'web-cursor-test',
|
||||
id: '$sourceHotspot',
|
||||
image: image,
|
||||
scale: 1,
|
||||
data: Uint8List.fromList(img.encodePng(image)),
|
||||
hotxOrigin: sourceHotspot.$1,
|
||||
hotyOrigin: sourceHotspot.$2,
|
||||
width: sourceSide,
|
||||
height: sourceSide,
|
||||
);
|
||||
final cursor = buildCursorOfCache(_CursorModel(), scale, cache);
|
||||
final session = cursor.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), (outputSide, outputSide));
|
||||
expect((registered!['hotx'], registered!['hoty']), expectedHotspot);
|
||||
session.dispose();
|
||||
await deleteCustomCursor(cache.updateGetKey(scale));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
#ifndef RUSTDESK_CURSOR_SIZE_H_
|
||||
#define RUSTDESK_CURSOR_SIZE_H_
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace cursor_size {
|
||||
|
||||
struct IconBitmaps {
|
||||
ICONINFO info{};
|
||||
~IconBitmaps() {
|
||||
if (info.hbmColor) DeleteObject(info.hbmColor);
|
||||
if (info.hbmMask) DeleteObject(info.hbmMask);
|
||||
}
|
||||
};
|
||||
|
||||
struct Surface {
|
||||
HDC dc = CreateCompatibleDC(nullptr);
|
||||
HBITMAP bitmap = nullptr;
|
||||
HGDIOBJ previous = nullptr;
|
||||
uint32_t* pixels = nullptr;
|
||||
int width = 0, height = 0;
|
||||
|
||||
~Surface() {
|
||||
if (previous) SelectObject(dc, previous);
|
||||
if (bitmap) DeleteObject(bitmap);
|
||||
if (dc) DeleteDC(dc);
|
||||
}
|
||||
|
||||
void Init(const BITMAP& source, bool monochrome) {
|
||||
width = source.bmWidth;
|
||||
height = monochrome ? source.bmHeight / 2 : source.bmHeight;
|
||||
if (!dc || width <= 0 || height <= 0) {
|
||||
throw std::runtime_error("Could not create the cursor measurement surface");
|
||||
}
|
||||
constexpr WORD kColorBits = 32;
|
||||
BITMAPINFO format{};
|
||||
format.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
format.bmiHeader.biWidth = width;
|
||||
format.bmiHeader.biHeight = -height;
|
||||
format.bmiHeader.biPlanes = 1;
|
||||
format.bmiHeader.biBitCount = kColorBits;
|
||||
format.bmiHeader.biCompression = BI_RGB;
|
||||
bitmap = CreateDIBSection(dc, &format, DIB_RGB_COLORS,
|
||||
reinterpret_cast<void**>(&pixels), nullptr, 0);
|
||||
if (!bitmap) throw std::runtime_error("Could not allocate the cursor bitmap");
|
||||
previous = SelectObject(dc, bitmap);
|
||||
if (!previous || previous == HGDI_ERROR) {
|
||||
previous = nullptr;
|
||||
throw std::runtime_error("Could not select the cursor bitmap");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
inline void IncludeVisiblePixels(const Surface& surface, uint32_t background,
|
||||
RECT& bounds) {
|
||||
constexpr uint32_t kRgbMask = 0x00ffffff;
|
||||
for (int y = 0; y < surface.height; ++y) {
|
||||
for (int x = 0; x < surface.width; ++x) {
|
||||
if ((surface.pixels[y * surface.width + x] & kRgbMask) == background) continue;
|
||||
bounds.left = (std::min)(bounds.left, static_cast<LONG>(x));
|
||||
bounds.right = (std::max)(bounds.right, static_cast<LONG>(x + 1));
|
||||
bounds.top = (std::min)(bounds.top, static_cast<LONG>(y));
|
||||
bounds.bottom = (std::max)(bounds.bottom, static_cast<LONG>(y + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline double WindowScale(HWND window) {
|
||||
const auto user32 = GetModuleHandleW(L"user32.dll");
|
||||
const auto window_dpi = reinterpret_cast<UINT(WINAPI*)(HWND)>(
|
||||
GetProcAddress(user32, "GetDpiForWindow"));
|
||||
const auto system_dpi = reinterpret_cast<UINT(WINAPI*)()>(
|
||||
GetProcAddress(user32, "GetDpiForSystem"));
|
||||
const auto metrics = reinterpret_cast<int(WINAPI*)(int, UINT)>(
|
||||
GetProcAddress(user32, "GetSystemMetricsForDpi"));
|
||||
// Preserve the system-DPI path on Windows versions without per-window metrics.
|
||||
if (!window_dpi || !system_dpi || !metrics) return 1.0;
|
||||
const UINT target_dpi = window_dpi(window);
|
||||
const UINT source_dpi = system_dpi();
|
||||
if (!target_dpi || !source_dpi) {
|
||||
throw std::runtime_error("Could not read the cursor window DPI");
|
||||
}
|
||||
const int target_size = metrics(SM_CXCURSOR, target_dpi);
|
||||
const int source_size = metrics(SM_CXCURSOR, source_dpi);
|
||||
if (target_size <= 0 || source_size <= 0) {
|
||||
throw std::runtime_error("Could not read the DPI-specific cursor metrics");
|
||||
}
|
||||
return static_cast<double>(target_size) / source_size;
|
||||
}
|
||||
|
||||
inline double SystemSize(HWND window) {
|
||||
HCURSOR cursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
IconBitmaps bitmaps;
|
||||
if (!cursor || !GetIconInfo(cursor, &bitmaps.info)) {
|
||||
throw std::runtime_error("Could not read the Windows system cursor");
|
||||
}
|
||||
BITMAP source{};
|
||||
const auto bitmap = bitmaps.info.hbmColor ? bitmaps.info.hbmColor : bitmaps.info.hbmMask;
|
||||
if (!GetObject(bitmap, sizeof(source), &source)) {
|
||||
throw std::runtime_error("Could not read the system cursor bitmap size");
|
||||
}
|
||||
Surface surface;
|
||||
surface.Init(source, !bitmaps.info.hbmColor);
|
||||
RECT bounds{surface.width, surface.height, 0, 0};
|
||||
// Drawing on both backgrounds measures alpha and legacy AND/XOR cursors.
|
||||
constexpr uint32_t kBlack = 0x00000000, kWhite = 0x00ffffff;
|
||||
for (const auto background : {kBlack, kWhite}) {
|
||||
std::fill_n(surface.pixels, surface.width * surface.height, background);
|
||||
if (!DrawIconEx(surface.dc, 0, 0, cursor, 0, 0, 0, nullptr, DI_NORMAL) || !GdiFlush()) {
|
||||
throw std::runtime_error("Could not draw the Windows system cursor");
|
||||
}
|
||||
IncludeVisiblePixels(surface, background, bounds);
|
||||
}
|
||||
if (bounds.right <= bounds.left) {
|
||||
throw std::runtime_error("System cursor has no visible pixels");
|
||||
}
|
||||
return (std::max)(bounds.right - bounds.left, bounds.bottom - bounds.top) *
|
||||
WindowScale(window);
|
||||
}
|
||||
|
||||
inline void Register(flutter::BinaryMessenger* messenger, HWND window) {
|
||||
flutter::MethodChannel<> channel(messenger, "org.rustdesk.rustdesk/cursor",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
channel.SetMethodCallHandler([window](const flutter::MethodCall<>& call,
|
||||
std::unique_ptr<flutter::MethodResult<>> result) {
|
||||
if (call.method_name() != "getSystemCursorSize") {
|
||||
result->NotImplemented();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
result->Success(flutter::EncodableValue(SystemSize(window)));
|
||||
} catch (const std::exception& error) {
|
||||
result->Error("cursor_size", error.what());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace cursor_size
|
||||
|
||||
#endif // RUSTDESK_CURSOR_SIZE_H_
|
||||
@@ -18,7 +18,6 @@
|
||||
#include <memory>
|
||||
|
||||
#include "win32_desktop.h"
|
||||
#include "cursor_size.h"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -101,8 +100,6 @@ bool FlutterWindow::OnCreate() {
|
||||
return false;
|
||||
}
|
||||
RegisterPlugins(flutter_controller_->engine());
|
||||
cursor_size::Register(flutter_controller_->engine()->messenger(),
|
||||
flutter_controller_->view()->GetNativeWindow());
|
||||
|
||||
flutter::MethodChannel<> channel(
|
||||
flutter_controller_->engine()->messenger(),
|
||||
@@ -149,8 +146,6 @@ bool FlutterWindow::OnCreate() {
|
||||
auto *flutter_view_controller =
|
||||
reinterpret_cast<flutter::FlutterViewController *>(controller);
|
||||
auto *registry = flutter_view_controller->engine();
|
||||
cursor_size::Register(registry->messenger(),
|
||||
flutter_view_controller->view()->GetNativeWindow());
|
||||
TextureRgbaRendererPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("TextureRgbaRendererPlugin"));
|
||||
FlutterGpuTextureRendererPluginCApiRegisterWithRegistrar(
|
||||
|
||||
Reference in New Issue
Block a user