fix(cursor): preserve unzoomed Web size across browser DPRs

This commit is contained in:
fufesou
2026-09-12 15:42:47 +08:00
parent 85e7c72ccd
commit d65faca21f
2 changed files with 140 additions and 1 deletions

View File

@@ -1127,7 +1127,7 @@ class _ImagePaintState extends State<ImagePaint> {
} else {
if (zoomCursor.value || isViewOriginal()) {
cursorScale = s;
} else {
} else if (!isWeb) {
// NSCursor and GdkCursor treat the bitmap size as logical
// pixels, so an unzoomed cursor must be shrunk by the DPR to
// keep 1 remote px == 1 physical px, the size Original view

View File

@@ -9,10 +9,16 @@ import 'dart:js' as js;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart' as model;
import 'package:flutter_hbb/web/custom_cursor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:image/image.dart' as img;
import 'package:provider/provider.dart';
class _CursorModel extends Fake implements model.CursorModel {
@override
@@ -23,6 +29,12 @@ class _CursorModel extends Fake implements model.CursorModel {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
for (final dpr in [1.0, 2.0]) {
testWidgets('ImagePaint Web $style zoom off DPR $dpr keeps source size',
(tester) => tester.runAsync(() => _checkPolicy(tester, style, dpr)));
}
}
test('Web cursor aligns CSS hotspots with rounded PNG dimensions', () async {
Map<String, dynamic>? registered;
final original = js.context['setByName'];
@@ -71,3 +83,130 @@ model.CursorData _data(ui.Image nativeImage, (double, double) hotspot) {
height: 48,
);
}
class _Image extends ChangeNotifier implements model.ImageModel {
@override
bool get useTextureRender => false;
@override
ui.Image? get image => null;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Canvas extends ChangeNotifier implements model.CanvasModel {
_Canvas(String style)
: viewStyle = model.ViewStyle(
style: style,
width: 200,
height: 160,
displayWidth: 400,
displayHeight: 320);
@override
final model.ViewStyle viewStyle;
@override
final imageOverflow = false.obs;
@override
bool get cursorEmbedded => false;
@override
Size get size => const Size(200, 160);
@override
double get scale => 0.5;
@override
double get x => 0;
@override
double get y => 0;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Input extends Fake implements InputModel {
@override
final relativeMouseMode = false.obs;
}
class _Peer extends Fake implements model.FfiModel {
@override
final pi = model.PeerInfo();
@override
bool get isPeerLinux => false;
}
class _FFI extends Fake implements model.FFI {
_FFI(this.canvasModel);
@override
final model.CanvasModel canvasModel;
@override
final inputModel = _Input();
@override
final ffiModel = _Peer();
}
Future<model.CursorModel> _loadCursor(model.FFI ffi, String id) async {
final cursor = model.CursorModel(WeakReference(ffi))..id = id;
await cursor.updateCursorData({
'id': id,
'width': '48',
'height': '48',
'hotx': '7',
'hoty': '9',
'scale': '1',
'colors': jsonEncode(List.filled(48 * 48 * 4, 255)),
});
addTearDown(() async {
for (final key in cursor.cachedKeys) {
await deleteCustomCursor(key);
}
cursor.disposeImages();
cursor.dispose();
});
return cursor;
}
Future<void> _checkPolicy(WidgetTester tester, String style, double dpr) async {
final originals = {
for (final key in ['isMobile', 'getByName', 'setByName'])
key: js.context[key]
};
js.context['isMobile'] = js.allowInterop(() => false);
js.context['getByName'] = js.allowInterop((String name, String value) => '');
Map<String, dynamic>? registered;
js.context['setByName'] = js.allowInterop((String name, String value) {
if (name == 'cursor') registered = jsonDecode(value);
});
addTearDown(() {
for (final entry in originals.entries) {
js.context[entry.key] = entry.value;
}
});
final canvas = _Canvas(style);
addTearDown(canvas.dispose);
final ffi = _FFI(canvas);
final cursor = await _loadCursor(ffi, '$style-$dpr');
await tester.pumpWidget(MediaQuery(
data: MediaQueryData(devicePixelRatio: dpr),
child: MultiProvider(
providers: [
ChangeNotifierProvider<model.ImageModel>(create: (_) => _Image()),
ChangeNotifierProvider<model.CanvasModel>.value(value: canvas),
ChangeNotifierProvider<model.CursorModel>.value(value: cursor),
],
child: ImagePaint(
ffi: ffi,
id: 'web-cursor-test',
zoomCursor: false.obs,
cursorOverImage: true.obs,
keyboardEnabled: true.obs,
remoteCursorMoved: false.obs)),
));
final session = tester
.widget<MouseRegion>(find.byType(MouseRegion))
.cursor
.createSession(1);
await session.activate();
session.dispose();
await tester.pumpWidget(const SizedBox.shrink());
final png =
img.decodePng(Uri.parse(registered!['url']).data!.contentAsBytes())!;
expect((png.width, png.height), (48, 48));
expect((registered!['hotx'], registered!['hoty']), (7, 9));
}