diff --git a/packages/flterm/lib/src/rendering/kitty_image_cache.dart b/packages/flterm/lib/src/rendering/kitty_image_cache.dart index a860cdbf..ad5e4e24 100644 --- a/packages/flterm/lib/src/rendering/kitty_image_cache.dart +++ b/packages/flterm/lib/src/rendering/kitty_image_cache.dart @@ -21,15 +21,15 @@ typedef KittyImageDecoder = /// RGBA formats reach this cache; anything else is stored as /// [KittyImageUnsupported] so subsequent paints do not retry. /// -/// Re-transmissions under the same id are detected by -/// [KittyImage.generation], so same-sized replacements cannot reuse stale -/// decoded images. +/// Re-transmissions under the same id are detected by libghostty's monotonic +/// image generation, including byte-level overwrites with unchanged dimensions. class KittyImageCache { final VoidCallback _onImageReady; final KittyImageDecoder _decodeImage; - final Map _entries = {}; - final Map _generations = {}; + final Map _fingerprints = {}; + final Map _activeDecodes = {}; + final Map _queuedDecodes = {}; /// [onImageReady] fires when a pending decode completes; typically /// wired to a render box's `markNeedsPaint`. @@ -44,7 +44,9 @@ class KittyImageCache { if (entry is KittyImageReady) entry.image.dispose(); } _entries.clear(); - _generations.clear(); + _fingerprints.clear(); + _activeDecodes.clear(); + _queuedDecodes.clear(); } /// Releases any cached entries whose id is not in [live]. @@ -52,24 +54,65 @@ class KittyImageCache { _entries.removeWhere((id, entry) { if (live.contains(id)) return false; if (entry is KittyImageReady) entry.image.dispose(); - _generations.remove(id); + _fingerprints.remove(id); + _activeDecodes.remove(id); + _queuedDecodes.remove(id); return true; }); } /// Returns the entry for [image], starting a decode on first lookup - /// or when the image's generation has changed. Never blocks. + /// or when its content generation has changed. Never blocks. KittyImageCacheEntry lookup(KittyImage image) { - final generation = image.generation; - final existing = _entries[image.id]; - if (existing != null && _generations[image.id] == generation) { + return _lookup( + imageId: image.id, + generation: image.generation, + width: image.width, + height: image.height, + rgba: () => _ensureRgba(image), + ); + } + + @visibleForTesting + KittyImageCacheEntry lookupRgba({ + required int imageId, + required int generation, + required int width, + required int height, + required Uint8List rgba, + }) => _lookup( + imageId: imageId, + generation: generation, + width: width, + height: height, + rgba: () => rgba, + ); + + KittyImageCacheEntry _lookup({ + required int imageId, + required int generation, + required int width, + required int height, + required Uint8List? Function() rgba, + }) { + final fingerprint = (generation: generation, width: width, height: height); + final existing = _entries[imageId]; + final previousFingerprint = _fingerprints[imageId]; + if (existing != null && previousFingerprint == fingerprint) { return existing; } - if (existing is KittyImageReady) existing.image.dispose(); - _entries[image.id] = KittyImagePending(); - _generations[image.id] = generation; - _beginDecode(image); - return _entries[image.id]!; + + final retainExisting = + existing is KittyImageReady && + previousFingerprint?.width == width && + previousFingerprint?.height == height; + if (!retainExisting) { + if (existing is KittyImageReady) existing.image.dispose(); + _entries[imageId] = KittyImagePending(); + } + _fingerprints[imageId] = fingerprint; + _beginDecode(imageId: imageId, fingerprint: fingerprint, rgba: rgba()); + return _entries[imageId]!; } /// Returns the cached entry for [imageId], or null if none. Unlike @@ -78,30 +121,88 @@ class KittyImageCache { /// Inserts a pre-decoded [image] under [imageId]. @visibleForTesting - void putReady(int imageId, Image image) { + void putReady(int imageId, Image image, {int generation = 0}) { final existing = _entries[imageId]; if (existing is KittyImageReady) existing.image.dispose(); _entries[imageId] = KittyImageReady(image); - _generations[imageId] = 0; + _fingerprints[imageId] = ( + generation: generation, + width: image.width, + height: image.height, + ); + _activeDecodes.remove(imageId); + _queuedDecodes.remove(imageId); } - void _beginDecode(KittyImage image) { - final imageId = image.id; - final generation = _generations[imageId]; - final rgba = _ensureRgba(image); + void _beginDecode({ + required int imageId, + required ({int generation, int width, int height}) fingerprint, + required Uint8List? rgba, + }) { if (rgba == null) { + final existing = _entries[imageId]; + if (existing is KittyImageReady) existing.image.dispose(); _entries[imageId] = KittyImageUnsupported(); + _activeDecodes.remove(imageId); + _queuedDecodes.remove(imageId); return; } - _decodeImage(rgba, image.width, image.height, .rgba8888, (decoded) { - if (_generations[imageId] == generation && - _entries[imageId] is KittyImagePending) { - _entries[imageId] = KittyImageReady(decoded); - _onImageReady(); - } else { - decoded.dispose(); - } - }); + final request = _KittyDecodeRequest( + imageId: imageId, + fingerprint: fingerprint, + rgba: rgba, + ); + if (_activeDecodes.containsKey(imageId)) { + _queuedDecodes[imageId] = request; + return; + } + _startDecode(request); + } + + void _startDecode(_KittyDecodeRequest request) { + _activeDecodes[request.imageId] = request; + _decodeImage( + request.rgba, + request.fingerprint.width, + request.fingerprint.height, + .rgba8888, + (decoded) => _finishDecode(request, decoded), + ); + } + + void _finishDecode(_KittyDecodeRequest request, Image decoded) { + final imageId = request.imageId; + if (!identical(_activeDecodes[imageId], request)) { + decoded.dispose(); + return; + } + _activeDecodes.remove(imageId); + + final queued = _queuedDecodes.remove(imageId); + final desired = _fingerprints[imageId]; + final isLatest = desired == request.fingerprint; + final isUsefulIntermediate = + queued != null && + desired == queued.fingerprint && + queued.fingerprint.width == request.fingerprint.width && + queued.fingerprint.height == request.fingerprint.height; + + var published = false; + if (isLatest || isUsefulIntermediate) { + final existing = _entries[imageId]; + _entries[imageId] = KittyImageReady(decoded); + if (existing is KittyImageReady) existing.image.dispose(); + published = true; + } else { + decoded.dispose(); + } + + if (queued != null && + _fingerprints[imageId] == queued.fingerprint && + _entries.containsKey(imageId)) { + _startDecode(queued); + } + if (published) _onImageReady(); } Uint8List? _ensureRgba(KittyImage image) { @@ -129,6 +230,18 @@ class KittyImageCache { } } +final class _KittyDecodeRequest { + final int imageId; + final ({int generation, int width, int height}) fingerprint; + final Uint8List rgba; + + const _KittyDecodeRequest({ + required this.imageId, + required this.fingerprint, + required this.rgba, + }); +} + /// Result of a cache lookup for a decoded image. sealed class KittyImageCacheEntry {} diff --git a/packages/flterm/test/rendering/kitty_image_cache_test.dart b/packages/flterm/test/rendering/kitty_image_cache_test.dart index 0b8c501d..b89a603d 100644 --- a/packages/flterm/test/rendering/kitty_image_cache_test.dart +++ b/packages/flterm/test/rendering/kitty_image_cache_test.dart @@ -12,10 +12,12 @@ import 'package:libghostty/libghostty.dart'; void main() { group('KittyImageCache', () { - Future testImage() { + Future testImage([ + List rgba = const [0xff, 0xff, 0xff, 0xff], + ]) { final completer = Completer(); ui.decodeImageFromPixels( - Uint8List.fromList([0xff, 0xff, 0xff, 0xff]), + Uint8List.fromList(rgba), 1, 1, ui.PixelFormat.rgba8888, @@ -45,6 +47,106 @@ void main() { }); }); + testWidgets('same-size retransmission keeps the previous image drawable', ( + tester, + ) async { + await tester.runAsync(() async { + var ready = Completer(); + final cache = KittyImageCache( + onImageReady: () { + if (!ready.isCompleted) ready.complete(); + }, + ); + addTearDown(cache.dispose); + + expect( + cache.lookupRgba( + imageId: 1, + generation: 10, + width: 1, + height: 1, + rgba: Uint8List.fromList([0xff, 0x00, 0x00, 0xff]), + ), + isA(), + ); + await ready.future; + + ready = Completer(); + final previous = cache.lookupById(1)! as KittyImageReady; + final replacing = cache.lookupRgba( + imageId: 1, + generation: 11, + width: 1, + height: 1, + rgba: Uint8List.fromList([0x00, 0xff, 0x00, 0xff]), + ); + expect(replacing, same(previous)); + + final previousBytes = await previous.image.toByteData(); + expect(previousBytes!.buffer.asUint8List(), [0xff, 0x00, 0x00, 0xff]); + + await ready.future; + final entry = cache.lookupById(1)! as KittyImageReady; + expect(entry, isNot(same(previous))); + final bytes = await entry.image.toByteData(); + expect(bytes!.buffer.asUint8List(), [0x00, 0xff, 0x00, 0xff]); + }); + }); + + testWidgets('coalesces rapid replacements to the newest queued frame', ( + tester, + ) async { + await tester.runAsync(() async { + final pending = + <({Uint8List rgba, ui.ImageDecoderCallback complete})>[]; + var readyCount = 0; + final cache = KittyImageCache( + onImageReady: () => readyCount++, + decodeImage: (rgba, width, height, format, complete) { + pending.add((rgba: rgba, complete: complete)); + }, + ); + addTearDown(cache.dispose); + + cache.lookupRgba( + imageId: 1, + generation: 10, + width: 1, + height: 1, + rgba: Uint8List.fromList([0xff, 0x00, 0x00, 0xff]), + ); + cache.lookupRgba( + imageId: 1, + generation: 11, + width: 1, + height: 1, + rgba: Uint8List.fromList([0x00, 0xff, 0x00, 0xff]), + ); + cache.lookupRgba( + imageId: 1, + generation: 12, + width: 1, + height: 1, + rgba: Uint8List.fromList([0x00, 0x00, 0xff, 0xff]), + ); + + expect(pending, hasLength(1)); + expect(pending.single.rgba, [0xff, 0x00, 0x00, 0xff]); + + pending.single.complete(await testImage([0xff, 0x00, 0x00, 0xff])); + expect(readyCount, 1); + expect(pending, hasLength(2)); + expect(pending.last.rgba, [0x00, 0x00, 0xff, 0xff]); + + pending.last.complete(await testImage([0x00, 0x00, 0xff, 0xff])); + expect(readyCount, 2); + + final entry = cache.lookupById(1)! as KittyImageReady; + final bytes = await entry.image.toByteData(); + expect(bytes!.buffer.asUint8List(), [0x00, 0x00, 0xff, 0xff]); + }); + }); + group('lookup', () { Uint8List transmitPixel({required int id, required List rgb}) { final payload = base64Encode(rgb); @@ -63,20 +165,21 @@ void main() { terminal.dispose(); }); - test('invalidates ready entry when image generation changes', () async { + test('retains ready entry while same-size generation decodes', () async { final cache = KittyImageCache(onImageReady: () {}); addTearDown(cache.dispose); final decoded = await testImage(); cache.putReady(7, decoded); + final previous = cache.lookupById(7); terminal.write(transmitPixel(id: 7, rgb: [0xff, 0x00, 0x00])); final image = KittyGraphics.of(terminal)!.image(7)!; final entry = cache.lookup(image); - expect(entry, isA()); + expect(entry, same(previous)); }); - test('discards stale pending decode after generation changes', () async { + test('queues the latest generation behind an active decode', () async { final callbacks = []; final cache = KittyImageCache( onImageReady: () {}, @@ -95,7 +198,10 @@ void main() { callbacks[0](stale); - expect(cache.lookupById(8), isA()); + expect(cache.lookupById(8), isA()); + expect(callbacks, hasLength(2)); + callbacks[1](await testImage()); + expect(cache.lookupById(8), isA()); }); }); });