diff --git a/lib/src/cursor_handler.dart b/lib/src/cursor_handler.dart index d1954ba..9dc5dda 100644 --- a/lib/src/cursor_handler.dart +++ b/lib/src/cursor_handler.dart @@ -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); } diff --git a/lib/src/decoders/ani_decoder.dart b/lib/src/decoders/ani_decoder.dart index 39fdbda..5e278f2 100644 --- a/lib/src/decoders/ani_decoder.dart +++ b/lib/src/decoders/ani_decoder.dart @@ -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 = []; - List<(int, int)>? seq; + List? seq; + List? 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.generate(frames.length, (i) => i); final ordered = []; final delays = []; - 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 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 = >{}; final frames = []; 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 _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> _parseFrames( diff --git a/lib/src/decoders/cur_decoder.dart b/lib/src/decoders/cur_decoder.dart index 5106161..747bd09 100644 --- a/lib/src/decoders/cur_decoder.dart +++ b/lib/src/decoders/cur_decoder.dart @@ -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 _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"); + } + } }