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:
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user