Preserve legacy macOS cursor dimensions with optional Retina artwork

This commit is contained in:
fufesou
2026-09-13 23:17:21 +08:00
parent 819535f1db
commit fbfa37c8fd
7 changed files with 78 additions and 9 deletions

View File

@@ -1118,7 +1118,10 @@ class _ImagePaintState extends State<ImagePaint> {
? cursor.cache ?? preDefaultCursor.cache
: preForbiddenCursor.cache;
final peerDpr = cache?.pixelRatio ?? 0;
if (!isWeb && isViewScaled() && zoomCursor.isFalse && peerDpr > 0) {
if (isViewScaled() && zoomCursor.isFalse && peerDpr > 0 &&
(!isWeb || widget.ffi.ffiModel.pi.platform == kPeerPlatformMacOS)) {
// Retina export is physical-sized; Web must undo that change too.
// Other Web host bitmaps retain their existing sizing policy.
// Adaptive/Custom scales the video, but Zoom cursor is off: preserve the
// cursor's logical size instead of multiplying it by the canvas scale.
// Divide by the source bitmap density to obtain logical cursor pixels.

View File

@@ -25,8 +25,15 @@ void main() {
_thinCursorTests();
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)));
for (final (platform, density) in [
(kPeerPlatformMacOS, null), (kPeerPlatformMacOS, 0.0),
(kPeerPlatformMacOS, 1.0), (kPeerPlatformMacOS, 2.0),
(kPeerPlatformLinux, 2.0), (kPeerPlatformWindows, 2.0),
]) {
testWidgets('Web $style zoom off DPR $dpr $platform density=$density',
(tester) => tester.runAsync(() => _checkPolicy(tester, style, dpr,
pixelRatio: density, platform: platform)));
}
}
}
test('Web cursor aligns CSS hotspots with rounded PNG dimensions', () async {
@@ -202,13 +209,18 @@ Map<String, dynamic> _captureCursor() {
Future<void> _checkPolicy(WidgetTester tester, String style, double dpr,
{bool zoom = false, (int, int) source = (48, 48),
(double, double) hotspot = (7, 9), (int, int) expectedSize = (48, 48),
(int, int) expectedHotspot = (7, 9)}) async {
(int, int) expectedHotspot = (7, 9), double? pixelRatio = 1,
String platform = kPeerPlatformMacOS}) async {
final registered = _captureCursor();
final canvas = _Canvas(style);
addTearDown(canvas.dispose);
final ffi = _FFI(canvas);
final cursor = await _loadCursor(ffi, '$style-$dpr-$zoom-$source',
source: source, hotspot: hotspot);
ffi.ffiModel.pi.platform = platform;
// Retina export changes the bitmap too; varying metadata alone misses this boundary.
final density = platform == kPeerPlatformMacOS && pixelRatio == 2 ? 2 : 1;
final cursor = await _loadCursor(ffi, '$style-$dpr-$zoom-$source-$platform-$pixelRatio',
source: (source.$1 * density, source.$2 * density), pixelRatio: pixelRatio,
hotspot: (hotspot.$1 * density, hotspot.$2 * density));
await tester.pumpWidget(MediaQuery(
data: MediaQueryData(devicePixelRatio: dpr),
child: MultiProvider(

View File

@@ -318,6 +318,8 @@ message CursorData {
bytes colors = 6;
// Physical bitmap pixels per remote logical pixel; 0 means unknown density.
double scale = 7;
// Optional physical image. Legacy receivers keep using the logical-sized fields above.
CursorData high_resolution = 8;
}
message CursorPosition {

View File

@@ -642,6 +642,7 @@ impl FlutterHandler {
impl InvokeUiSession for FlutterHandler {
fn set_cursor_data(&self, cd: CursorData) {
let cd = cd.high_resolution.as_ref().unwrap_or(&cd);
let colors = hbb_common::compress::decompress(&cd.colors);
self.push_event(
"cursor_data",

View File

@@ -81,6 +81,16 @@ unsafe fn render(image: id, bitmap: id, size: NSSize) -> ResultType<()> {
}
pub(super) unsafe fn data(cursor: id, id: u64, scale: f64) -> ResultType<CursorData> {
// Older receivers ignore density. Keep their image logical-sized while
// retaining the complete physical artwork for density-aware controllers.
let mut legacy = physical_data(cursor, id, 1.0)?;
if scale > 1.0 {
legacy.high_resolution = Some(physical_data(cursor, id, scale)?).into();
}
Ok(legacy)
}
unsafe fn physical_data(cursor: id, id: u64, scale: f64) -> ResultType<CursorData> {
let image: id = msg_send![cursor, image];
let logical: NSSize = msg_send![image, size];
let size = NSSize::new(
@@ -142,6 +152,9 @@ fn straight_rgba(pixels: &[u8]) -> Vec<u8> {
colors
}
#[cfg(test)]
mod compat_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -184,7 +197,7 @@ mod tests {
let cursor = StrongPtr::new(
msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(4.0, 9.0)],
);
let result = data(*cursor, 1, 2.0).unwrap();
let result = physical_data(*cursor, 1, 2.0).unwrap();
assert_eq!(
(result.width, result.height, result.hotx, result.hoty),
(18, 36, 8, 18)
@@ -206,7 +219,7 @@ mod tests {
initWithImage: image hotSpot: NSPoint::new(point.0, point.1)]);
let actual: NSPoint = msg_send![*c, hotSpot];
assert_eq!((actual.x, actual.y), point);
let result = data(*c, 1, 2.0).unwrap();
let result = physical_data(*c, 1, 2.0).unwrap();
assert_eq!((result.hotx, result.hoty), pixels);
assert_eq!(result.colors.as_ref(), expected);
}
@@ -244,7 +257,7 @@ mod tests {
let cursor = StrongPtr::new(
msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(1.0, 1.0)],
);
let result = data(*cursor, 1, SCALE).unwrap();
let result = physical_data(*cursor, 1, SCALE).unwrap();
// Older Sciter receivers encode the received bytes directly as PNG.
let mut png = Vec::new();
repng::encode(

View File

@@ -0,0 +1,35 @@
use super::*;
use hbb_common::protobuf::Message;
use objc::rc::autoreleasepool;
#[test]
fn retina_export_preserves_legacy_dimensions_and_hotspot() {
autoreleasepool(|| unsafe {
let logical = NSSize::new(32.0, 32.0);
let image: id = msg_send![class!(NSImage), alloc];
let image = StrongPtr::new(msg_send![image, initWithSize: logical]);
let rep = bitmap(NSSize::new(64.0, 64.0)).unwrap();
let buffer: *mut u8 = msg_send![*rep, bitmapData];
ptr::write_bytes(buffer, 255, 64 * 64 * CHANNELS);
let (): () = msg_send![*rep, setSize: logical];
let (): () = msg_send![*image, addRepresentation: *rep];
let cursor: id = msg_send![class!(NSCursor), alloc];
let cursor = StrongPtr::new(msg_send![cursor,
initWithImage: *image hotSpot: NSPoint::new(8.0, 12.0)]);
let exported = data(*cursor, 123, 2.0).unwrap();
let legacy = CursorData::parse_from_bytes(&exported.write_to_bytes().unwrap()).unwrap();
assert_eq!((legacy.width, legacy.height), (32, 32));
assert_eq!((legacy.hotx, legacy.hoty), (8, 12));
assert_eq!(legacy.colors.len(), 32 * 32 * CHANNELS);
assert_eq!(legacy.scale, 1.0);
assert!(legacy.colors.iter().all(|channel| *channel == 255));
let physical = legacy.high_resolution.as_ref().unwrap();
assert_eq!((physical.width, physical.height), (64, 64));
assert_eq!((physical.hotx, physical.hoty), (16, 24));
assert_eq!(physical.colors.len(), 64 * 64 * CHANNELS);
assert_eq!((physical.id, physical.scale), (legacy.id, 2.0));
assert!(physical.colors.iter().all(|channel| *channel == 255));
assert!(data(*cursor, 123, 1.0).unwrap().high_resolution.is_none());
});
}

View File

@@ -422,6 +422,9 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()>
#[cfg(not(all(target_os = "linux", feature = "drm")))]
let cache_key = hcursor;
data.colors = hbb_common::compress::compress(&data.colors[..]).into();
if let Some(physical) = data.high_resolution.as_mut() {
physical.colors = hbb_common::compress::compress(&physical.colors).into();
}
let mut tmp = Message::new();
tmp.set_cursor_data(data);
msg = Arc::new(tmp);