Fix cursor decoding in some special cases

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-09-02 10:53:06 +02:00
parent 3ad9efcf77
commit bcb81b129d
3 changed files with 94 additions and 41 deletions
+5
View File
@@ -260,6 +260,11 @@ void _startAnimation(String name, bool rtl) {
_animationTimer?.cancel();
final registration = _animated[name];
if (registration == null) return;
registration.index = 0;
// Show the first frame immediately; a held frame (e.g. a long first delay)
// should still be visible the moment the cursor is activated.
final suffix = rtl ? _rtlSuffix : "";
unawaited(setCursor("$name#0$suffix"));
_scheduleFrame(name, rtl);
}
+58 -41
View File
@@ -10,8 +10,9 @@ import "cur_decoder.dart";
/// Decodes Windows animated cursor (`.ani`) resources.
///
/// An `.ani` file is a RIFF `ACON` container holding an `anih` header, a
/// `LIST fram` of `icon` subchunks (each a single-frame `.cur`), and an
/// optional `seq ` chunk that plays frames out of order. This decoder reads
/// `LIST fram` of `icon` subchunks (each a single-frame `.cur`), an optional
/// `seq ` chunk that plays frames out of order, and an optional `rate` chunk
/// giving each play step's duration in 1/60ths of a second. This decoder reads
/// those structures and produces an [AnimatedCursor].
class AniDecoder {
AniDecoder._();
@@ -28,7 +29,8 @@ class AniDecoder {
// Walk the top-level RIFF chunks.
var baseDelayMicros = 16 * 1000;
final frames = <Cursor>[];
List<(int, int)>? seq;
List<int>? seq;
List<int>? rates;
for (var offset = 12; offset + 8 <= bytes.length;) {
final id = _fourCc(view, offset);
final size = view.getUint32(offset + 4, Endian.little);
@@ -49,15 +51,18 @@ class AniDecoder {
frames.addAll(await _parseFrames(view, body + 4, body + size));
}
} else if (id == "seq ") {
// The seq chunk is a list of (frameIndex, delay) pairs; the delay is
// a per-step rate in 1/60ths of a second (0 meaning "use iDispRate").
final pairCount = size ~/ 4;
// The seq chunk is an array of frame indices into the fram LIST,
// defining the playback order (frames may repeat).
seq = [
for (var i = 0; i < pairCount; i++)
(
view.getUint16(body + i * 4, Endian.little),
view.getUint16(body + i * 4 + 2, Endian.little),
),
for (var i = 0; i < size ~/ 4; i++)
view.getUint32(body + i * 4, Endian.little),
];
} else if (id == "rate") {
// The rate chunk arrays a display rate in 1/60ths of a second for each
// step of the seq chunk (or each frame in index order when absent).
rates = [
for (var i = 0; i < size ~/ 4; i++)
view.getUint32(body + i * 4, Endian.little),
];
}
@@ -67,20 +72,16 @@ class AniDecoder {
if (frames.isEmpty) throw const FormatException("ani has no frames");
final play = seq ?? List<int>.generate(frames.length, (i) => i);
final ordered = <Cursor>[];
final delays = <Duration>[];
if (seq != null) {
for (final (index, delay) in seq) {
if (index < 0 || index >= frames.length) continue;
ordered.add(frames[index]);
final micros = delay > 0 ? delay * 16666 : baseDelayMicros;
delays.add(Duration(microseconds: micros));
}
} else {
ordered.addAll(frames);
for (var i = 0; i < frames.length; i++) {
delays.add(Duration(microseconds: baseDelayMicros));
}
for (var i = 0; i < play.length; i++) {
final index = play[i];
if (index < 0 || index >= frames.length) continue;
ordered.add(frames[index]);
final rate = rates != null && i < rates.length ? rates[i] : 0;
final micros = rate > 0 ? rate * 16666 : baseDelayMicros;
delays.add(Duration(microseconds: micros));
}
if (ordered.isEmpty)
throw const FormatException("ani has no playable frames");
@@ -89,6 +90,11 @@ class AniDecoder {
}
/// Decodes [bytes] into an animated cursor, scaling frames per [options].
///
/// A `seq ` chunk may reference the same frame (and so the same
/// [Cursor.image]) multiple times in different play steps. Scaling reuses
/// the result for each duplicate so the shared source image is consumed
/// exactly once.
static Future<AnimatedCursor> decodeWithOptions(
Uint8List bytes,
DecodeOptions options,
@@ -97,31 +103,42 @@ class AniDecoder {
final target = options.targetSize;
if (target == null && options.systemScale == 1.0) return animated;
final cache = <Cursor, Future<Cursor>>{};
final frames = <Cursor>[];
for (final cursor in animated.frames) {
if (target != null) {
frames.add(
await CursorScaler.scaleTo(
cursor.image,
cursor.hotspotX,
cursor.hotspotY,
target,
),
);
} else {
frames.add(
await CursorScaler.scaleTo(
cursor.image,
cursor.hotspotX,
cursor.hotspotY,
cursor.image.width * options.systemScale,
),
);
final cached = cache[cursor];
if (cached != null) {
frames.add(await cached);
continue;
}
final scaled = _scaleFrame(cursor, target, options.systemScale);
cache[cursor] = scaled;
frames.add(await scaled);
}
return AnimatedCursor(frames: frames, frameDelays: animated.frameDelays);
}
static Future<Cursor> _scaleFrame(
Cursor cursor,
double? target,
double systemScale,
) async {
if (target != null) {
return CursorScaler.scaleTo(
cursor.image,
cursor.hotspotX,
cursor.hotspotY,
target,
);
}
return CursorScaler.scaleTo(
cursor.image,
cursor.hotspotX,
cursor.hotspotY,
cursor.image.width * systemScale,
);
}
/// Parses consecutive `icon` subchunks starting at [start] (inclusive) up to
/// [end] (exclusive) within the `LIST fram` body.
static Future<List<Cursor>> _parseFrames(
+31
View File
@@ -71,6 +71,23 @@ class CurDecoder {
throw const FormatException("cursor bitmap data truncated");
}
final data = Uint8List.sublistView(bytes, offset, offset + size);
// Modern cursors may embed a full PNG (or other compressed image) instead
// of a raw DIB bitmap. Detect it and hand it to the image codec directly.
if (data.length >= 8 &&
data[0] == 0x89 && // PNG magic
data[1] == 0x50 && // 'P'
data[2] == 0x4e && // 'N'
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),
);
}
final dib = ByteData.sublistView(bytes, offset, offset + size);
if (dib.lengthInBytes < 40) {
throw const FormatException("cursor DIB data too small");
@@ -208,4 +225,18 @@ class CurDecoder {
);
return completer.future;
}
static Future<ui.Image> _decodeCompressed(Uint8List bytes) async {
try {
final codec = await ui.instantiateImageCodec(bytes);
try {
final frame = await codec.getNextFrame();
return frame.image;
} finally {
codec.dispose();
}
} on Exception {
throw const FormatException("unsupported compressed cursor data");
}
}
}