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:
fufesou
2026-09-10 21:31:24 +08:00
parent f5b98b32f2
commit f96d00d9d1
5 changed files with 260 additions and 23 deletions

View File

@@ -1120,7 +1120,7 @@ class _ImagePaintState extends State<ImagePaint> {
} else {
if (zoomCursor.value || isViewOriginal()) {
cursorScale = s;
} else {
} else if (isLinux || isMacOS) {
// 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
@@ -1421,19 +1421,8 @@ class CursorPaint extends StatelessWidget {
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.
} else if (isLinux || isMacOS) {
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;
}

View File

@@ -2879,9 +2879,21 @@ class CursorData {
int _doubleToInt(double v) => (v * 10e6).round().toInt();
bool get _usesLogicalCursorPixels => isLinux || isMacOS;
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
? max(minBitmapSize, pixels.round())
: pixels.toInt();
}
double _checkUpdateScale(double scale) {
double oldScale = this.scale;
if (scale != 1.0) {
if (!_usesLogicalCursorPixels && scale != 1.0) {
// Update data if scale changed.
final tgtWidth = (width * scale).toInt();
final tgtHeight = (height * scale).toInt();
@@ -2907,8 +2919,8 @@ class CursorData {
img2.encodePng(
img2.copyResize(
image,
width: (width * scale).toInt(),
height: (height * scale).toInt(),
width: _scaledDimension(width, scale),
height: _scaledDimension(height, scale),
interpolation: img2.Interpolation.average,
),
),
@@ -2917,8 +2929,8 @@ class CursorData {
}
this.scale = scale;
hotx = hotxOrigin * scale;
hoty = hotyOrigin * scale;
hotx = hotxOrigin * (_usesLogicalCursorPixels ? scaledWidth / width : scale);
hoty = hotyOrigin * (_usesLogicalCursorPixels ? scaledHeight / height : scale);
return scale;
}

View File

@@ -1,16 +1,39 @@
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) return hotspot;
// GDK takes integer hotspots inside the bitmap; avoid truncating toward zero.
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) {
@@ -23,6 +46,12 @@ 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]
@@ -32,11 +61,11 @@ MouseCursor buildCursorOfCache(
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);
..buffer = buffer
..width = width
..height = cache.scaledHeight
..hotX = _nativeHotspot(cache.hotx, cache.scaledWidth)
..hotY = _nativeHotspot(cache.hoty, cache.scaledHeight));
cursor.addKey(key);
}
return FlutterCustomMemoryImageCursor(key: key);