Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 144 additions & 31 deletions packages/flterm/lib/src/rendering/kitty_image_cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, KittyImageCacheEntry> _entries = {};
final Map<int, int> _generations = {};
final Map<int, ({int generation, int width, int height})> _fingerprints = {};
final Map<int, _KittyDecodeRequest> _activeDecodes = {};
final Map<int, _KittyDecodeRequest> _queuedDecodes = {};

/// [onImageReady] fires when a pending decode completes; typically
/// wired to a render box's `markNeedsPaint`.
Expand All @@ -44,32 +44,75 @@ 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].
void evict(Set<int> live) {
_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
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {}

Expand Down
118 changes: 112 additions & 6 deletions packages/flterm/test/rendering/kitty_image_cache_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import 'package:libghostty/libghostty.dart';

void main() {
group('KittyImageCache', () {
Future<ui.Image> testImage() {
Future<ui.Image> testImage([
List<int> rgba = const [0xff, 0xff, 0xff, 0xff],
]) {
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
Uint8List.fromList([0xff, 0xff, 0xff, 0xff]),
Uint8List.fromList(rgba),
1,
1,
ui.PixelFormat.rgba8888,
Expand Down Expand Up @@ -45,6 +47,106 @@ void main() {
});
});

testWidgets('same-size retransmission keeps the previous image drawable', (
tester,
) async {
await tester.runAsync(() async {
var ready = Completer<void>();
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<KittyImagePending>(),
);
await ready.future;

ready = Completer<void>();
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<int> rgb}) {
final payload = base64Encode(rgb);
Expand All @@ -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<KittyImagePending>());
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 = <ui.ImageDecoderCallback>[];
final cache = KittyImageCache(
onImageReady: () {},
Expand All @@ -95,7 +198,10 @@ void main() {

callbacks[0](stale);

expect(cache.lookupById(8), isA<KittyImagePending>());
expect(cache.lookupById(8), isA<KittyImageReady>());
expect(callbacks, hasLength(2));
callbacks[1](await testImage());
expect(cache.lookupById(8), isA<KittyImageReady>());
});
});
});
Expand Down