mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-19 02:41:10 +03:00
fix(flutter): correct cursor scaling and Linux cursor clipping
Keep Linux/macOS bitmap sizing and painted cursor DPI conversion consistent, and derive hotspots from the actual scaled bitmap. Pad tall Linux native cursor buffers with transparency to preserve the lower half in the hardware cursor plane. Cover bitmap sizing, hotspots, artwork preservation, and painting with focused regression tests.
This commit is contained in:
111
flutter/test/cursor_dpi_scale_test.dart
Normal file
111
flutter/test/cursor_dpi_scale_test.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
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, scale, artwork size, Linux hotspot.
|
||||
const _cases = [
|
||||
((9, 18), (4.0, 9.0), 1 / 3, (3, 6), (1.0, 3.0)),
|
||||
((17, 23), (4.0, 4.0), 1 / 3, (6, 8), (1.0, 1.0)),
|
||||
((32, 16), (16.0, 8.0), 0.5, (16, 8), (8.0, 4.0)),
|
||||
((24, 24), (11.0, 11.0), 1 / 3, (8, 8), (4.0, 4.0)),
|
||||
((3, 3), (2.0, 2.0), 1 / 3, (1, 1), (0.0, 0.0)),
|
||||
((1, 48), (0.0, 24.0), 1 / 3, (1, 16), (0.0, 8.0)),
|
||||
((9, 18), (4.0, 9.0), 1.0, (9, 18), (4.0, 9.0)),
|
||||
((9, 18), (4.0, 9.0), 7 / 18, (4, 7), (2.0, 4.0)),
|
||||
((24, 24), (4.0, 4.0), 0.1, (2, 2), (0.0, 0.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);
|
||||
}
|
||||
|
||||
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: 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 {
|
||||
const channel = MethodChannel('flutter_custom_cursor');
|
||||
Map<dynamic, dynamic>? registered;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||
(call) async {
|
||||
expect(call.method, '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() {
|
||||
for (final (source, hotspot, scale, size, linuxHotspot) 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 = img.decodePng(data.data!)!;
|
||||
final native = img.decodePng(cursor['buffer'] as Uint8List)!;
|
||||
final width = Platform.isLinux && size.$2 > size.$1 ? size.$2 : size.$1;
|
||||
|
||||
expect(data.scale, scale);
|
||||
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 ? linuxHotspot : (data.hotx, data.hoty));
|
||||
_expectArtwork(native, artwork);
|
||||
if (width == artwork.width) {
|
||||
expect(cursor['buffer'], data.data);
|
||||
}
|
||||
}, skip: Platform.isWindows);
|
||||
}
|
||||
}
|
||||
96
flutter/test/cursor_paint_scale_test.dart
Normal file
96
flutter/test/cursor_paint_scale_test.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
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, 80);
|
||||
const _canvasOffset = Offset(15, 10);
|
||||
const _canvasScale = 0.5;
|
||||
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 _CanvasModel extends ChangeNotifier implements CanvasModel {
|
||||
_CanvasModel(String style)
|
||||
: viewStyle = ViewStyle(
|
||||
style: style,
|
||||
width: _viewport.width,
|
||||
height: _viewport.height,
|
||||
displayWidth: _viewport.width.toInt(),
|
||||
displayHeight: _viewport.height.toInt(),
|
||||
);
|
||||
|
||||
@override
|
||||
final ViewStyle viewStyle;
|
||||
@override
|
||||
double get x => _canvasOffset.dx;
|
||||
@override
|
||||
double get y => _canvasOffset.dy;
|
||||
@override
|
||||
double get scale => _canvasScale;
|
||||
@override
|
||||
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final (style, zoom, dpr, scale) in [
|
||||
(kRemoteViewStyleAdaptive, false, 2.0, 0.5),
|
||||
(kRemoteViewStyleAdaptive, false, 3.0, 1 / 3),
|
||||
(kRemoteViewStyleAdaptive, true, 3.0, _canvasScale),
|
||||
(kRemoteViewStyleOriginal, false, 2.0, _canvasScale),
|
||||
]) {
|
||||
testWidgets('$style zoom=$zoom dpr=$dpr keeps cursor scale and hotspot',
|
||||
(tester) async {
|
||||
final image =
|
||||
(await tester.runAsync(() => createTestImage(width: 9, height: 18)))!;
|
||||
addTearDown(image.dispose);
|
||||
await tester.pumpWidget(MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<CursorModel>(
|
||||
create: (_) => _CursorModel(image)),
|
||||
ChangeNotifierProvider<CanvasModel>(
|
||||
create: (_) => _CanvasModel(style)),
|
||||
],
|
||||
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);
|
||||
expect((Offset(painter.x, painter.y) + _hotspot) * scale,
|
||||
_remotePosition * _canvasScale + _canvasOffset);
|
||||
}, skip: Platform.isWindows && style == kRemoteViewStyleAdaptive && !zoom);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user