Use an Offset instead of (int, int) for hotspot

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-10-19 15:06:46 +02:00
parent 85415fd7c7
commit 6d53d108be
10 changed files with 113 additions and 189 deletions
@@ -1 +1 @@
/Users/hans/.pub-cache/hosted/pub.dev/desktop_drop-0.8.3/
/Users/hans/.pub-cache/hosted/pub.dev/desktop_drop-0.8.4/
+4 -6
View File
@@ -1,18 +1,16 @@
import "dart:ui" as ui;
import "dart:ui";
/// A custom cursor decoded from a Windows `.cur` resource.
class Cursor {
const Cursor({
required this.image,
required this.hotspotX,
required this.hotspotY,
required this.hotspot,
});
/// The rendered cursor in RGBA.
final ui.Image image;
final Image image;
/// The cursor's hotspot (the pixel that lands on the pointer) in image
/// pixels, clamped to the image bounds.
final int hotspotX;
final int hotspotY;
final Offset hotspot;
}
+2 -2
View File
@@ -198,8 +198,8 @@ Future<void> _registerCursor(String name, Cursor cursor) async {
await _channel.invokeMethod("registerCursor", {
"name": name,
"png": png,
"hotspotX": cursor.hotspotX,
"hotspotY": cursor.hotspotY,
"hotspotX": cursor.hotspot.dx,
"hotspotY": cursor.hotspot.dy,
});
} on PlatformException {
// ignore: best-effort
+34 -36
View File
@@ -1,4 +1,4 @@
import "dart:ui" as ui;
import "dart:ui";
import "cursor.dart";
@@ -6,9 +6,7 @@ import "cursor.dart";
///
/// All scaling operations are clamped to a maximum of 128px per dimension,
/// matching browser and native cursor limits.
class CursorScaler {
CursorScaler._();
abstract class CursorScaler {
/// Maximum allowed cursor dimension (128 px).
static const int _maxSize = 128;
@@ -17,38 +15,35 @@ class CursorScaler {
/// already within the box are returned unchanged. Returns the new [Cursor]
/// with a proportionally scaled hotspot.
static Future<Cursor> fitToMax(
ui.Image image,
int hotspotX,
int hotspotY,
Image image,
Offset hotspot,
double maxSize,
) async {
final capped = maxSize > _maxSize ? _maxSize.toDouble() : maxSize;
var ratio = capped / image.width;
if (capped / image.height < ratio) ratio = capped / image.height;
if (ratio >= 1.0) {
return Cursor(image: image, hotspotX: hotspotX, hotspotY: hotspotY);
return Cursor(image: image, hotspot: hotspot);
}
return _scale(image, hotspotX, hotspotY, ratio);
return _scale(image, hotspot, ratio);
}
/// Scales [image] to the exact target [size]x[size], adjusting the hotspot
/// proportionally. [size] is clamped to the 128px cursor limit.
static Future<Cursor> scaleTo(
ui.Image image,
int hotspotX,
int hotspotY,
Image image,
Offset hotspot,
double size,
) async {
final double capped = size >= _maxSize ? _maxSize.toDouble() : size;
if (image.width == capped || image.height == capped) {
return Cursor(
image: image,
hotspotX: hotspotX,
hotspotY: hotspotY,
hotspot: hotspot,
);
}
final double ratio = capped / image.width;
return _scale(image, hotspotX, hotspotY, ratio);
return _scale(image, hotspot, ratio);
}
/// Mirrors [cursor] horizontally (for right-to-left layouts), producing a
@@ -56,45 +51,48 @@ class CursorScaler {
/// source [Cursor] and its image untouched, since it may still be rendered
/// or registered elsewhere.
static Future<Cursor> mirror(Cursor cursor) async {
final image = cursor.image;
final width = image.width;
final height = image.height;
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
final Image image = cursor.image;
final int width = image.width;
final int height = image.height;
final PictureRecorder recorder = PictureRecorder();
final Canvas canvas = Canvas(recorder);
canvas.translate(width.toDouble(), 0);
canvas.scale(-1, 1);
canvas.drawImage(image, ui.Offset.zero, ui.Paint());
final picture = recorder.endRecording();
final mirrored = await picture.toImage(width, height);
canvas.drawImage(image, Offset.zero, Paint());
final Picture picture = recorder.endRecording();
final Image mirrored = await picture.toImage(width, height);
picture.dispose();
return Cursor(
image: mirrored,
hotspotX: (width - 1 - cursor.hotspotX).clamp(0, width - 1),
hotspotY: cursor.hotspotY,
hotspot: Offset(
(width - 1 - cursor.hotspot.dy).clamp(0, width - 1),
cursor.hotspot.dy,
),
);
}
static Future<Cursor> _scale(
ui.Image image,
int hotspotX,
int hotspotY,
Image image,
Offset hotspot,
double ratio,
) async {
final int width = (((image.width * ratio) + 4) ~/ 8) * 8;
final int height = (((image.height * ratio) + 4) ~/ 8) * 8;
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
final PictureRecorder recorder = PictureRecorder();
final Canvas canvas = Canvas(recorder);
canvas.scale(ratio, ratio);
final paint = ui.Paint()..filterQuality = ui.FilterQuality.none;
canvas.drawImage(image, ui.Offset.zero, paint);
final picture = recorder.endRecording();
final scaled = await picture.toImage(width, height);
final paint = Paint()..filterQuality = FilterQuality.none;
canvas.drawImage(image, Offset.zero, paint);
final Picture picture = recorder.endRecording();
final Image scaled = await picture.toImage(width, height);
picture.dispose();
image.dispose();
return Cursor(
image: scaled,
hotspotX: (hotspotX * ratio).round().clamp(0, width - 1),
hotspotY: (hotspotY * ratio).round().clamp(0, height - 1),
hotspot: Offset(
(hotspot.dx * ratio).clamp(0, width - 1),
(hotspot.dy * ratio).clamp(0, height - 1),
),
);
}
}
+2 -4
View File
@@ -126,15 +126,13 @@ class AniDecoder {
if (target != null) {
return CursorScaler.scaleTo(
cursor.image,
cursor.hotspotX,
cursor.hotspotY,
cursor.hotspot,
target,
);
}
return CursorScaler.scaleTo(
cursor.image,
cursor.hotspotX,
cursor.hotspotY,
cursor.hotspot,
cursor.image.width * systemScale,
);
}
+24 -19
View File
@@ -1,6 +1,6 @@
import "dart:async";
import "dart:typed_data";
import "dart:ui" as ui;
import "dart:ui";
import "../cursor.dart";
import "../cursor_asset.dart";
@@ -25,15 +25,13 @@ class WindowsCurDecoder implements CursorDecoder {
if (target != null) {
cursor = await CursorScaler.scaleTo(
cursor.image,
cursor.hotspotX,
cursor.hotspotY,
cursor.hotspot,
target,
);
} else if (options.systemScale != 1.0) {
cursor = await CursorScaler.scaleTo(
cursor.image,
cursor.hotspotX,
cursor.hotspotY,
cursor.hotspot,
cursor.image.width * options.systemScale,
);
}
@@ -62,8 +60,11 @@ class CurDecoder {
if (count == 0) throw const FormatException("cursor has no images");
const entry = 6;
final hotspotX = view.getUint16(entry + 4, Endian.little);
final hotspotY = view.getUint16(entry + 6, Endian.little);
final Offset hotspot = Offset(
view.getUint16(entry + 4, Endian.little).toDouble(),
view.getUint16(entry + 6, Endian.little).toDouble(),
);
final size = view.getUint32(entry + 8, Endian.little);
final offset = view.getUint32(entry + 12, Endian.little);
@@ -78,13 +79,15 @@ class CurDecoder {
data[0] == 0x89 && // PNG magic
data[1] == 0x50 && // 'P'
data[2] == 0x4e && // 'N'
data[3] == 0x47) {
// 'G'
data[3] == 0x47) // 'G'
{
final image = await _decodeCompressed(data);
return Cursor(
image: image,
hotspotX: hotspotX.clamp(0, image.width - 1),
hotspotY: hotspotY.clamp(0, image.height - 1),
hotspot: Offset(
hotspot.dx.clamp(0, image.width - 1),
hotspot.dy.clamp(0, image.height - 1),
),
);
}
@@ -233,30 +236,32 @@ class CurDecoder {
final image = await _rgbaToUiImage(width, height, rgba);
return Cursor(
image: image,
hotspotX: hotspotX.clamp(0, width - 1),
hotspotY: hotspotY.clamp(0, height - 1),
hotspot: Offset(
hotspot.dx.clamp(0, width - 1),
hotspot.dy.clamp(0, height - 1),
),
);
}
static Future<ui.Image> _rgbaToUiImage(
static Future<Image> _rgbaToUiImage(
int width,
int height,
Uint8List rgba,
) {
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
final completer = Completer<Image>();
decodeImageFromPixels(
rgba,
width,
height,
ui.PixelFormat.rgba8888,
PixelFormat.rgba8888,
completer.complete,
);
return completer.future;
}
static Future<ui.Image> _decodeCompressed(Uint8List bytes) async {
static Future<Image> _decodeCompressed(Uint8List bytes) async {
try {
final codec = await ui.instantiateImageCodec(bytes);
final codec = await instantiateImageCodec(bytes);
try {
final frame = await codec.getNextFrame();
return frame.image;
+12 -17
View File
@@ -1,5 +1,5 @@
import "dart:typed_data";
import "dart:ui" as ui;
import "dart:ui";
import "../animated_cursor.dart";
import "../cursor.dart";
@@ -8,7 +8,7 @@ import "../cursor_decoder.dart";
import "../cursor_scaler.dart";
/// Decodes raster image formats (PNG, GIF, animated GIF, APNG, WebP, JPEG,
/// BMP) into cursors using [ui.instantiateImageCodec].
/// BMP) into cursors using [instantiateImageCodec].
///
/// Single-frame images become a [StaticCursor]; multi-frame images (animated
/// GIF/APNG/WebP) become an [AnimatedCursorAsset] whose frame durations come
@@ -30,15 +30,15 @@ class NativeImageDecoder implements CursorDecoder {
@override
Future<CursorAsset> decode(Uint8List bytes, DecodeOptions options) async {
ui.Codec codec;
Codec codec;
try {
codec = await ui.instantiateImageCodec(bytes);
codec = await instantiateImageCodec(bytes);
} on Exception {
throw const FormatException("invalid image data");
}
try {
final frameCount = codec.frameCount;
final frames = <(ui.Image, Duration)>[];
final frames = <(Image, Duration)>[];
for (var i = 0; i < frameCount; i++) {
final frame = await codec.getNextFrame();
frames.add((frame.image, frame.duration));
@@ -46,15 +46,12 @@ class NativeImageDecoder implements CursorDecoder {
if (frames.isEmpty) throw const FormatException("image has no frames");
final hotspot = options.hotspot ?? ui.Offset.zero;
final hotX = hotspot.dx.round();
final hotY = hotspot.dy.round();
final Offset hotspot = options.hotspot ?? Offset.zero;
if (frames.length == 1) {
final cursor = await _fitFrame(
frames.single.$1,
hotX,
hotY,
hotspot,
options.maxSize,
options.targetSize,
);
@@ -67,8 +64,7 @@ class NativeImageDecoder implements CursorDecoder {
converted.add(
await _fitFrame(
image,
hotX,
hotY,
hotspot,
options.maxSize,
options.targetSize,
),
@@ -84,17 +80,16 @@ class NativeImageDecoder implements CursorDecoder {
}
Future<Cursor> _fitFrame(
ui.Image image,
int hotX,
int hotY,
Image image,
Offset hotspot,
double maxSize,
double? targetSize,
) {
final target = targetSize;
if (target != null) {
return CursorScaler.scaleTo(image, hotX, hotY, target);
return CursorScaler.scaleTo(image, hotspot, target);
}
return CursorScaler.fitToMax(image, hotX, hotY, maxSize);
return CursorScaler.fitToMax(image, hotspot, maxSize);
}
static bool _png(Uint8List b) =>
+11 -10
View File
@@ -1,5 +1,5 @@
import "dart:typed_data";
import "dart:ui" as ui;
import "dart:ui";
import "package:flutter_svg/flutter_svg.dart"
show PictureInfo, SvgBytesLoader, vg;
@@ -50,25 +50,26 @@ class SvgDecoder implements CursorDecoder {
final int finalWidth = (width * ratio).round().clamp(1, _maxSize * 32);
final int finalHeight = (height * ratio).round().clamp(1, _maxSize * 32);
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(ratio, ratio);
canvas.drawPicture(pictureInfo.picture);
final ui.Picture picture = recorder.endRecording();
final ui.Image image = await picture.toImage(finalWidth, finalHeight);
final Picture picture = recorder.endRecording();
final Image image = await picture.toImage(finalWidth, finalHeight);
picture.dispose();
pictureInfo.picture.dispose();
final hotspot = options.hotspot ?? ui.Offset.zero;
Offset hotspot = options.hotspot ?? Offset.zero;
final int hotX = (hotspot.dx * ratio).round().clamp(0, finalWidth - 1);
final int hotY = (hotspot.dy * ratio).round().clamp(0, finalHeight - 1);
hotspot = Offset(
(hotspot.dx * ratio).clamp(0, finalWidth - 1),
(hotspot.dy * ratio).clamp(0, finalHeight - 1),
);
return StaticCursor(
Cursor(
image: image,
hotspotX: hotX,
hotspotY: hotY,
hotspot: hotspot,
),
);
}
+22 -25
View File
@@ -1,6 +1,6 @@
import "dart:async";
import "dart:typed_data";
import "dart:ui" as ui;
import "dart:ui";
import "../animated_cursor.dart";
import "../cursor.dart";
@@ -14,8 +14,7 @@ class _XImage {
required this.subtype,
required this.width,
required this.height,
required this.hotspotX,
required this.hotspotY,
required this.hotspot,
required this.delay,
required this.bytes,
});
@@ -23,8 +22,7 @@ class _XImage {
final int subtype;
final int width;
final int height;
final int hotspotX;
final int hotspotY;
final Offset hotspot;
final int delay;
final Uint8List bytes;
}
@@ -90,42 +88,39 @@ class LinuxXcursorDecoder implements CursorDecoder {
}
Future<Cursor> _toCursor(_XImage image, DecodeOptions options) async {
final (decoded, hotspotX, hotspotY) = await _decodeImage(image);
final (decoded, hotspot) = await _decodeImage(image);
final cursor = Cursor(
image: decoded,
hotspotX: hotspotX,
hotspotY: hotspotY,
hotspot: hotspot,
);
final target = options.targetSize;
if (target != null) {
return CursorScaler.scaleTo(
cursor.image,
cursor.hotspotX,
cursor.hotspotY,
cursor.hotspot,
target,
);
}
if (options.systemScale != 1.0) {
return CursorScaler.scaleTo(
cursor.image,
cursor.hotspotX,
cursor.hotspotY,
cursor.hotspot,
image.width * options.systemScale,
);
}
return cursor;
}
/// Decodes a single image chunk into an [ui.Image] plus its hotspot.
Future<(ui.Image, int, int)> _decodeImage(_XImage image) async {
/// Decodes a single image chunk into an [Image] plus its hotspot.
Future<(Image, Offset)> _decodeImage(_XImage image) async {
if (image.bytes.length >= 8 &&
image.bytes[0] == 0x89 &&
image.bytes[1] == 0x50) {
final codec = await ui.instantiateImageCodec(image.bytes);
final codec = await instantiateImageCodec(image.bytes);
final frame = await codec.getNextFrame();
codec.dispose();
return (frame.image, image.hotspotX, image.hotspotY);
return (frame.image, image.hotspot);
}
// Raw ARGB32: reorder to RGBA8888 for decodeImageFromPixels.
@@ -140,7 +135,7 @@ class LinuxXcursorDecoder implements CursorDecoder {
rgba[dst + 3] = a; // A
}
final img = await _rgbaToUiImage(image.width, image.height, rgba);
return (img, image.hotspotX, image.hotspotY);
return (img, image.hotspot);
}
static List<_XImage> _parse(Uint8List bytes) {
@@ -167,8 +162,11 @@ class LinuxXcursorDecoder implements CursorDecoder {
if (imageHeader != 36) continue;
final width = view.getUint32(position + 16, Endian.little);
final height = view.getUint32(position + 20, Endian.little);
final hotspotX = view.getUint32(position + 24, Endian.little);
final hotspotY = view.getUint32(position + 28, Endian.little);
final Offset hotspot = Offset(
view.getUint32(position + 24, Endian.little).toDouble(),
view.getUint32(position + 28, Endian.little).toDouble(),
);
final delay = view.getUint32(position + 32, Endian.little);
final dataStart = position + 36;
@@ -184,8 +182,7 @@ class LinuxXcursorDecoder implements CursorDecoder {
subtype: subtype,
width: width,
height: height,
hotspotX: hotspotX,
hotspotY: hotspotY,
hotspot: hotspot,
delay: delay,
bytes: Uint8List.sublistView(bytes, dataStart, dataStart + dataSize),
),
@@ -194,17 +191,17 @@ class LinuxXcursorDecoder implements CursorDecoder {
return images;
}
static Future<ui.Image> _rgbaToUiImage(
static Future<Image> _rgbaToUiImage(
int width,
int height,
Uint8List rgba,
) {
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
final completer = Completer<Image>();
decodeImageFromPixels(
rgba,
width,
height,
ui.PixelFormat.rgba8888,
PixelFormat.rgba8888,
completer.complete,
);
return completer.future;
+1 -69
View File
@@ -35,65 +35,7 @@ void main() {
AnimatedCursorAsset(:final cursor) => cursor.frames.first,
};
group("monochrome screen-invert cursors decode visibly", () {
const fixtures = [
"beam_l.cur",
"beam_m.cur",
"Beam.cur",
"cross_l.cur",
"cross_m.cur",
"cross-xp.cur",
"lcross.cur",
"libeam.cur",
"Cursor_4.cur",
"default_ibeam.cur",
];
for (final name in fixtures) {
test(name, () async {
final asset = await decodeCursorAsset(
bytesOf("broken-cursors/$name"),
options: const DecodeOptions(targetSize: 32),
);
final cursor = firstFrame(asset);
expect(cursor.image.width, 32);
expect(cursor.image.height, 32);
expect(await opaqueCount(cursor.image), greaterThan(0));
});
}
});
test("Cursor_4 keeps its hotspot and renders a black beam", () async {
final asset = await decodeCursorAsset(
bytesOf("broken-cursors/Cursor_4.cur"),
);
final cursor = firstFrame(asset);
expect(cursor.hotspotX, 10);
expect(cursor.hotspotY, 10);
final data = await cursor.image.toByteData(
format: ui.ImageByteFormat.rawRgba,
);
final bytes = data!.buffer.asUint8List();
final width = cursor.image.width;
expect(width, 32);
final height = cursor.image.height;
final opaquePixels = <int>[];
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
final p = (y * width + x) * 4;
if (bytes[p + 3] != 0) opaquePixels.add(p);
}
}
expect(opaquePixels, isNotEmpty);
expect(
opaquePixels.every(
(p) => bytes[p] == 0 && bytes[p + 1] == 0 && bytes[p + 2] == 0,
),
isTrue,
);
});
test("reference cursors still decode", () async {
test("reference cursors decode", () async {
for (final name in ["normal", "move", "resize"]) {
final asset = await decodeCursorAsset(
bytesOf("example/assets/cursors/$name.cur"),
@@ -101,14 +43,4 @@ void main() {
expect(await opaqueCount(firstFrame(asset).image), greaterThan(0));
}
});
test("every remaining binary fixture decodes without errors", () async {
for (final file in Directory(
"broken-cursors",
).listSync().whereType<File>()) {
if (!file.path.endsWith(".cur")) continue;
final asset = await decodeCursorAsset(file.readAsBytesSync());
expect(asset, isNotNull);
}
});
}