mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-14 00:11:01 +03:00
fix(cursor): delegate native image scaling to cursor plugin
Use the original Flutter image and source hotspot with explicit DPR, retaining the existing view and minimum-size policies. Pin the plugin implementation from rustdesk-org/flutter_custom_cursor#1.
This commit is contained in:
@@ -2855,6 +2855,9 @@ 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;
|
||||
double scale;
|
||||
Uint8List? data;
|
||||
final double hotxOrigin;
|
||||
@@ -2868,6 +2871,7 @@ class CursorData {
|
||||
required this.peerId,
|
||||
required this.id,
|
||||
required this.image,
|
||||
required this.nativeImage,
|
||||
required this.scale,
|
||||
required this.data,
|
||||
required this.hotxOrigin,
|
||||
@@ -2879,7 +2883,9 @@ class CursorData {
|
||||
|
||||
int _doubleToInt(double v) => (v * 10e6).round().toInt();
|
||||
|
||||
double _checkUpdateScale(double scale) {
|
||||
// Keep the minimum-size policy here. Native callers let the plugin rasterize
|
||||
// the original ui.Image; Web keeps the encoded-image resizing path.
|
||||
double _checkUpdateScale(double scale, {bool resizeImage = true}) {
|
||||
double oldScale = this.scale;
|
||||
if (scale != 1.0) {
|
||||
// Update data if scale changed.
|
||||
@@ -2892,7 +2898,7 @@ class CursorData {
|
||||
}
|
||||
}
|
||||
|
||||
if (_doubleToInt(oldScale) != _doubleToInt(scale)) {
|
||||
if (resizeImage && _doubleToInt(oldScale) != _doubleToInt(scale)) {
|
||||
if (isWindows) {
|
||||
data = img2
|
||||
.copyResize(
|
||||
@@ -2922,8 +2928,8 @@ class CursorData {
|
||||
return scale;
|
||||
}
|
||||
|
||||
String updateGetKey(double scale) {
|
||||
scale = _checkUpdateScale(scale);
|
||||
String updateGetKey(double scale, {bool resizeImage = true}) {
|
||||
scale = _checkUpdateScale(scale, resizeImage: resizeImage);
|
||||
return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}';
|
||||
}
|
||||
}
|
||||
@@ -2976,9 +2982,10 @@ class PredefinedCursor {
|
||||
// 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(
|
||||
final nativeImage = await img.decodeImageFromPixels(
|
||||
data, defaultImg.width, defaultImg.height, ui.PixelFormat.rgba8888);
|
||||
if (_image == null) {
|
||||
_image = nativeImage;
|
||||
if (nativeImage == null) {
|
||||
print("decodeImageFromPixels failed, pre-defined cursor $id");
|
||||
return;
|
||||
}
|
||||
@@ -2993,6 +3000,7 @@ class PredefinedCursor {
|
||||
peerId: '',
|
||||
id: id,
|
||||
image: _image2!.clone(),
|
||||
nativeImage: nativeImage,
|
||||
scale: scale,
|
||||
data: data,
|
||||
hotxOrigin:
|
||||
@@ -3471,6 +3479,7 @@ class CursorModel with ChangeNotifier {
|
||||
peerId: peerId,
|
||||
id: id,
|
||||
image: imgOrigin,
|
||||
nativeImage: image,
|
||||
scale: 1.0,
|
||||
data: data,
|
||||
hotxOrigin: hotx,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/models/model.dart';
|
||||
|
||||
deleteCustomCursor(String key) =>
|
||||
@@ -16,27 +20,32 @@ 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;
|
||||
final key = '${cache.updateGetKey(scale, resizeImage: false)}_$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);
|
||||
|
||||
@@ -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: aa9e68a38b2efad7411d39ddb1a762cb2c6170eb
|
||||
resolved-ref: aa9e68a38b2efad7411d39ddb1a762cb2c6170eb
|
||||
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: aa9e68a38b2efad7411d39ddb1a762cb2c6170eb
|
||||
window_size:
|
||||
git:
|
||||
url: https://github.com/21pages/flutter-desktop-embedding.git
|
||||
|
||||
108
flutter/test/cursor_plugin_test.dart
Normal file
108
flutter/test/cursor_plugin_test.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
// These tests drive the lifecycle normally owned by MouseTracker.
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Future<CursorData> _data() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
ui.Canvas(recorder).drawColor(const ui.Color(0xff123456), ui.BlendMode.src);
|
||||
final picture = recorder.endRecording();
|
||||
final nativeImage = await picture.toImage(20, 30);
|
||||
picture.dispose();
|
||||
addTearDown(nativeImage.dispose);
|
||||
return CursorData(
|
||||
peerId: 'plugin',
|
||||
id: 'edit',
|
||||
image: img.Image(width: 20, height: 30, numChannels: 4),
|
||||
nativeImage: nativeImage,
|
||||
scale: 1,
|
||||
data: Uint8List.fromList([1, 2]),
|
||||
hotxOrigin: 4,
|
||||
hotyOrigin: 16,
|
||||
width: 20,
|
||||
height: 30);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final view = binding.platformDispatcher.views.single;
|
||||
final channel = Platform.isWindows
|
||||
? SystemChannels.mouseCursor
|
||||
: const MethodChannel('flutter_custom_cursor');
|
||||
late List<Map<dynamic, dynamic>> registrations;
|
||||
setUp(() {
|
||||
registrations = [];
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||
(call) async {
|
||||
if (call.method.startsWith('createCustomCursor')) {
|
||||
final args = call.arguments as Map<dynamic, dynamic>;
|
||||
registrations.add(args);
|
||||
return args['name'];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
tearDown(() {
|
||||
view.resetDevicePixelRatio();
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('native bridge delegates rasterization and retains the minimum policy',
|
||||
() => _checkRasterization(view, registrations));
|
||||
test('live DPR changes invalidate a cached native cursor',
|
||||
() => _checkDprChange(view, registrations));
|
||||
}
|
||||
|
||||
Future<void> _checkRasterization(
|
||||
TestFlutterView view, List<Map<dynamic, dynamic>> registrations) async {
|
||||
view.devicePixelRatio = 2;
|
||||
final data = await _data();
|
||||
final originalBytes = data.data;
|
||||
final cursor = _CursorModel();
|
||||
final session = buildCursorOfCache(cursor, 0.5, data).createSession(1);
|
||||
await session.activate();
|
||||
session.dispose();
|
||||
final args = registrations.single;
|
||||
final pixelsPerUnit = Platform.isWindows ? 1 : 2;
|
||||
expect(data.scale, 0.6);
|
||||
expect(identical(data.data, originalBytes), isTrue);
|
||||
expect(args['height'], 18 * pixelsPerUnit);
|
||||
expect(args['width'], (Platform.isLinux ? 18 : 12) * pixelsPerUnit);
|
||||
expect(args['imagePixelRatio'], 2.0);
|
||||
expect(args['hotX'], Platform.isMacOS ? 4.8 : 2 * pixelsPerUnit);
|
||||
expect(args['hotY'], Platform.isMacOS ? 19.2 : 10 * pixelsPerUnit);
|
||||
await deleteCustomCursor(args['name'] as String);
|
||||
}
|
||||
|
||||
Future<void> _checkDprChange(
|
||||
TestFlutterView view, List<Map<dynamic, dynamic>> registrations) async {
|
||||
final data = await _data();
|
||||
final cursor = _CursorModel();
|
||||
for (final dpr in [2.0, 1.0]) {
|
||||
view.devicePixelRatio = dpr;
|
||||
final session = buildCursorOfCache(cursor, 1, data).createSession(1);
|
||||
await session.activate();
|
||||
session.dispose();
|
||||
}
|
||||
expect(registrations, hasLength(2));
|
||||
expect(registrations[0]['name'], isNot(registrations[1]['name']));
|
||||
for (final args in registrations) {
|
||||
await deleteCustomCursor(args['name'] as String);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user