mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-10 11:19:45 +00:00
kitty/gfx: add generation stamps, delete transmit time
Add a generation counter to the kitty graphics image storage. Every
content mutation (image transmit/replace, placement add, delete)
assigns the storage a fresh stamp, and every image is stamped when
it is added or replaced.
This solves two problems:
First, a retransmission of the same image ID with identical dimensions
was previously undetectable by anything comparing width, height, format,
and data length; the per-image stamp changes on every add/replace, so caches
keyed on it always see the change. Second, the dirty flag was the only
storage-wide signal, and it is also set by scrolling and resizing, which move
placements without changing contents. The generation is only bumped by content
mutations, so an unchanged value means the placement set and all
image data are identical and consumers can skip re-reading them,
recomputing only placement geometry.
The generation replaces Image.transmit_time entirely: newest-image
lookup by number, eviction ordering, and the renderer's texture
staleness checks all key on it now. A monotonic counter strictly
orders transmissions where Instant-based times could collide within
clock resolution (the renderer previously assumed equal timestamps
meant identical images), and this removes a syscall and an error
path per transmission.
Delete commands now only mark a mutation (dirty flag and
generation) when they actually remove something. A delete-all runs
on every screen clear, so previously every ESC [ 2 J dirtied the
image state even with no images stored. Eviction via setLimit also
now marks the state dirty, which it previously did not.
Both generations are exposed through libghostty-vt as
GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION and
GHOSTTY_KITTY_IMAGE_DATA_GENERATION (uint64_t), and the headers now
document that stored image data is always post-inflate/post-decode:
COMPRESSION always reports NONE, FORMAT is never PNG, and DATA_PTR
is raw pixels ready for GPU upload.
uint64_t gen = 0;
ghostty_kitty_graphics_get(
graphics, GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION, &gen);
if (gen == last_gen) return; // nothing changed, skip re-reads
This commit is contained in:
@@ -125,6 +125,17 @@ int main() {
|
||||
}
|
||||
printf("\nKitty graphics storage is available.\n");
|
||||
|
||||
/*
|
||||
* The storage-wide generation changes on every image/placement
|
||||
* mutation. Renderers can compare it against the value from the
|
||||
* previous frame: if unchanged, placement iteration and image
|
||||
* staleness checks can be skipped entirely.
|
||||
*/
|
||||
uint64_t generation = 0;
|
||||
ghostty_kitty_graphics_get(graphics, GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION,
|
||||
&generation);
|
||||
printf("Storage generation: %llu\n", (unsigned long long)generation);
|
||||
|
||||
/* Iterate placements to find the image ID. */
|
||||
GhosttyKittyGraphicsPlacementIterator iter = NULL;
|
||||
if (ghostty_kitty_graphics_placement_iterator_new(NULL, &iter) != GHOSTTY_SUCCESS) {
|
||||
@@ -170,20 +181,30 @@ int main() {
|
||||
uint32_t width = 0, height = 0, number = 0;
|
||||
GhosttyKittyImageFormat format = 0;
|
||||
size_t data_len = 0;
|
||||
uint64_t image_generation = 0;
|
||||
|
||||
ghostty_kitty_graphics_image_get_multi(image, 5,
|
||||
/*
|
||||
* The per-image generation changes on every add/replace of this
|
||||
* image ID, so it detects retransmissions even when the size and
|
||||
* format are unchanged. Texture caches should key staleness on it.
|
||||
*/
|
||||
ghostty_kitty_graphics_image_get_multi(image, 6,
|
||||
(GhosttyKittyGraphicsImageData[]){
|
||||
GHOSTTY_KITTY_IMAGE_DATA_NUMBER,
|
||||
GHOSTTY_KITTY_IMAGE_DATA_WIDTH,
|
||||
GHOSTTY_KITTY_IMAGE_DATA_HEIGHT,
|
||||
GHOSTTY_KITTY_IMAGE_DATA_FORMAT,
|
||||
GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN,
|
||||
GHOSTTY_KITTY_IMAGE_DATA_GENERATION,
|
||||
},
|
||||
(void*[]){ &number, &width, &height, &format, &data_len },
|
||||
(void*[]){ &number, &width, &height, &format, &data_len,
|
||||
&image_generation },
|
||||
NULL);
|
||||
|
||||
printf(" image: number=%u size=%ux%u format=%d data_len=%zu\n",
|
||||
number, width, height, format, data_len);
|
||||
printf(" image: number=%u size=%ux%u format=%d data_len=%zu "
|
||||
"generation=%llu\n",
|
||||
number, width, height, format, data_len,
|
||||
(unsigned long long)image_generation);
|
||||
|
||||
/* Compute the rendered pixel size and grid size. */
|
||||
uint32_t px_w = 0, px_h = 0, cols = 0, rows = 0;
|
||||
|
||||
@@ -84,6 +84,28 @@ extern "C" {
|
||||
* - ghostty_kitty_graphics_placement_rect() — bounding rectangle as a
|
||||
* @ref GhosttySelection.
|
||||
*
|
||||
* ## Change Detection
|
||||
*
|
||||
* Generation stamps allow renderers to cheaply detect whether Kitty
|
||||
* graphics state changed between frames:
|
||||
*
|
||||
* - @ref GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION is a storage-wide stamp
|
||||
* updated on any transmit, placement, or delete. If unchanged, the
|
||||
* placement set and all image data are identical and both placement
|
||||
* snapshots and per-image staleness checks can be skipped. Placement
|
||||
* geometry can still change independently (scrolling moves
|
||||
* placements), so ghostty_kitty_graphics_placement_render_info()
|
||||
* should still be recomputed on dirty frames.
|
||||
* - @ref GHOSTTY_KITTY_IMAGE_DATA_GENERATION is a per-image stamp
|
||||
* changed on every add/replace of that image ID. Texture caches
|
||||
* should treat a cached texture as stale when this differs from the
|
||||
* cached value; dimension/length heuristics cannot detect a
|
||||
* same-sized retransmission.
|
||||
*
|
||||
* Stamps are unique and monotonically increasing process-wide, so
|
||||
* caches keyed on a generation value never alias across screens
|
||||
* (main/alternate), resets, or terminals.
|
||||
*
|
||||
* ## Lifetime and Thread Safety
|
||||
*
|
||||
* All handles borrowed from the terminal (GhosttyKittyGraphics,
|
||||
@@ -119,6 +141,29 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Output type: GhosttyKittyGraphicsPlacementIterator *
|
||||
*/
|
||||
GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1,
|
||||
|
||||
/**
|
||||
* Generation stamp of the last content mutation to this storage:
|
||||
* any image transmit/replace, placement add, or delete. Zero means
|
||||
* the storage has never been mutated (and is therefore empty).
|
||||
*
|
||||
* If the generation is unchanged since a previous query, the set of
|
||||
* placements and all image data are identical, so placement iteration
|
||||
* and image staleness checks can be skipped entirely. Note that
|
||||
* placement *geometry* may still have changed (scrolling and resizing
|
||||
* move placements without changing the storage contents), so rendering
|
||||
* geometry such as ghostty_kitty_graphics_placement_render_info()
|
||||
* must still be recomputed for frames marked dirty.
|
||||
*
|
||||
* Stamps are unique and monotonically increasing process-wide: a
|
||||
* value observed from any storage never recurs for different content,
|
||||
* even across screen switches (main vs. alternate screen have
|
||||
* independent storages) or terminal resets. It is therefore safe to
|
||||
* key caches on this value alone.
|
||||
*
|
||||
* Output type: uint64_t *
|
||||
*/
|
||||
GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION = 2,
|
||||
GHOSTTY_KITTY_GRAPHICS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyKittyGraphicsData;
|
||||
|
||||
@@ -255,6 +300,12 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/**
|
||||
* Pixel format of a Kitty graphics image.
|
||||
*
|
||||
* Note that stored images are always fully decoded:
|
||||
* GHOSTTY_KITTY_IMAGE_FORMAT_PNG is never returned by
|
||||
* ghostty_kitty_graphics_image_get() because PNG payloads are decoded
|
||||
* to GHOSTTY_KITTY_IMAGE_FORMAT_RGBA before storage. The PNG value
|
||||
* exists only for protocol-level completeness.
|
||||
*
|
||||
* @ingroup kitty_graphics
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
@@ -269,6 +320,12 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/**
|
||||
* Compression of a Kitty graphics image.
|
||||
*
|
||||
* Note that stored images are always decompressed:
|
||||
* GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE payloads are inflated
|
||||
* before storage, so ghostty_kitty_graphics_image_get() always reports
|
||||
* GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE. Consumers never need to
|
||||
* inflate image data themselves.
|
||||
*
|
||||
* @ingroup kitty_graphics
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
@@ -315,14 +372,17 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
GHOSTTY_KITTY_IMAGE_DATA_HEIGHT = 4,
|
||||
|
||||
/**
|
||||
* Pixel format of the image.
|
||||
* Pixel format of the image. Never GHOSTTY_KITTY_IMAGE_FORMAT_PNG;
|
||||
* PNG payloads are decoded to RGBA before storage.
|
||||
*
|
||||
* Output type: GhosttyKittyImageFormat *
|
||||
*/
|
||||
GHOSTTY_KITTY_IMAGE_DATA_FORMAT = 5,
|
||||
|
||||
/**
|
||||
* Compression of the image.
|
||||
* Compression of the image. Always
|
||||
* GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE; compressed payloads are
|
||||
* inflated before storage.
|
||||
*
|
||||
* Output type: GhosttyKittyImageCompression *
|
||||
*/
|
||||
@@ -332,17 +392,41 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Borrowed pointer to the raw pixel data. Valid as long as the
|
||||
* underlying terminal is not mutated.
|
||||
*
|
||||
* The data is always fully decoded, uncompressed pixels in the
|
||||
* format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads
|
||||
* are inflated and PNG payloads are decoded to RGBA at transmission
|
||||
* time, before the image is stored. Consumers can upload this
|
||||
* directly to the GPU without any decode step.
|
||||
*
|
||||
* Output type: const uint8_t **
|
||||
*/
|
||||
GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7,
|
||||
|
||||
/**
|
||||
* Length of the raw pixel data in bytes.
|
||||
* Length of the raw pixel data in bytes. Always equal to
|
||||
* width * height * bytes-per-pixel for the reported format.
|
||||
*
|
||||
* Output type: size_t *
|
||||
*/
|
||||
GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8,
|
||||
|
||||
/**
|
||||
* Generation stamp assigned when this image was added to (or
|
||||
* replaced in) the storage. A changed generation for a given image
|
||||
* ID means the pixel contents may have changed even when the
|
||||
* dimensions, format, and data length are identical (e.g. a
|
||||
* retransmission of the same image ID), so texture caches must key
|
||||
* staleness on this value rather than on size heuristics.
|
||||
*
|
||||
* Stamps are unique and monotonically increasing process-wide and
|
||||
* are drawn from the same sequence as
|
||||
* GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored
|
||||
* image, so zero can be used as an "empty" sentinel by callers.
|
||||
*
|
||||
* Output type: uint64_t *
|
||||
*/
|
||||
GHOSTTY_KITTY_IMAGE_DATA_GENERATION = 9,
|
||||
|
||||
GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyKittyGraphicsImageData;
|
||||
|
||||
|
||||
@@ -192,9 +192,9 @@ pub const State = struct {
|
||||
return;
|
||||
};
|
||||
|
||||
// For transmit time we always just use the current time
|
||||
// and overwrite the overlay.
|
||||
const transmit_time = try std.time.Instant.now();
|
||||
// Overlays are always considered new content, so we take a
|
||||
// fresh generation stamp to force replacing any existing one.
|
||||
const generation = terminal.kitty.graphics.nextGeneration();
|
||||
|
||||
// Ensure we have space for our overlay placement. Do this before
|
||||
// we upload our image so we don't have to deal with cleaning
|
||||
@@ -207,7 +207,7 @@ pub const State = struct {
|
||||
try self.prepImage(
|
||||
alloc,
|
||||
.overlay,
|
||||
transmit_time,
|
||||
generation,
|
||||
pending,
|
||||
);
|
||||
errdefer comptime unreachable;
|
||||
@@ -525,14 +525,14 @@ pub const State = struct {
|
||||
self: *State,
|
||||
alloc: Allocator,
|
||||
id: Id,
|
||||
transmit_time: std.time.Instant,
|
||||
generation: u64,
|
||||
pending: Image.Pending,
|
||||
) PrepImageError!void {
|
||||
// If this image exists and its transmit time is the same we assume
|
||||
// it is the identical image so we don't need to send it to the GPU.
|
||||
// If this image exists and its generation is the same it is the
|
||||
// identical image so we don't need to send it to the GPU.
|
||||
const gop = try self.images.getOrPut(alloc, id);
|
||||
if (gop.found_existing and
|
||||
gop.value_ptr.transmit_time.order(transmit_time) == .eq)
|
||||
gop.value_ptr.generation == generation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -570,7 +570,7 @@ pub const State = struct {
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{
|
||||
.image = new_image,
|
||||
.transmit_time = undefined,
|
||||
.generation = 0,
|
||||
};
|
||||
} else {
|
||||
gop.value_ptr.image.markForReplace(
|
||||
@@ -586,7 +586,7 @@ pub const State = struct {
|
||||
log.warn("error preparing image for upload err={}", .{err});
|
||||
return error.ImageConversionError;
|
||||
};
|
||||
gop.value_ptr.transmit_time = transmit_time;
|
||||
gop.value_ptr.generation = generation;
|
||||
}
|
||||
|
||||
/// Prepare the provided Kitty image for upload to the GPU by copying its
|
||||
@@ -599,7 +599,7 @@ pub const State = struct {
|
||||
try self.prepImage(
|
||||
alloc,
|
||||
.{ .kitty = image.id },
|
||||
image.transmit_time,
|
||||
image.generation,
|
||||
.{
|
||||
.width = image.width,
|
||||
.height = image.height,
|
||||
@@ -688,7 +688,13 @@ pub const Id = union(enum) {
|
||||
/// The map used for storing images.
|
||||
pub const ImageMap = std.AutoHashMapUnmanaged(Id, struct {
|
||||
image: Image,
|
||||
transmit_time: std.time.Instant,
|
||||
|
||||
/// The generation of the terminal image this was created from
|
||||
/// (see terminal.kitty.graphics.Image.generation). Used to detect
|
||||
/// staleness: a differing generation for the same ID means the
|
||||
/// contents changed and the texture must be replaced. Zero is
|
||||
/// never a valid stored generation so it marks "not yet uploaded".
|
||||
generation: u64,
|
||||
});
|
||||
|
||||
/// The state for a single image that is to be rendered.
|
||||
|
||||
@@ -52,11 +52,13 @@ else
|
||||
pub const Data = enum(c_int) {
|
||||
invalid = 0,
|
||||
placement_iterator = 1,
|
||||
generation = 2,
|
||||
|
||||
pub fn OutType(comptime self: Data) type {
|
||||
return switch (self) {
|
||||
.invalid => void,
|
||||
.placement_iterator => PlacementIterator,
|
||||
.generation => u64,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -129,6 +131,7 @@ fn getTyped(
|
||||
.layer_filter = it.layer_filter,
|
||||
};
|
||||
},
|
||||
.generation => out.* = storage.generation,
|
||||
}
|
||||
return .success;
|
||||
}
|
||||
@@ -178,6 +181,7 @@ pub const ImageData = enum(c_int) {
|
||||
compression = 6,
|
||||
data_ptr = 7,
|
||||
data_len = 8,
|
||||
generation = 9,
|
||||
|
||||
pub fn OutType(comptime self: ImageData) type {
|
||||
return switch (self) {
|
||||
@@ -187,6 +191,7 @@ pub const ImageData = enum(c_int) {
|
||||
.compression => ImageCompression,
|
||||
.data_ptr => [*]const u8,
|
||||
.data_len => usize,
|
||||
.generation => u64,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -258,6 +263,7 @@ fn imageGetTyped(
|
||||
.compression => out.* = image.compression,
|
||||
.data_ptr => out.* = image.data.ptr,
|
||||
.data_len => out.* = image.data.len,
|
||||
.generation => out.* = image.generation,
|
||||
}
|
||||
|
||||
return .success;
|
||||
@@ -1708,3 +1714,259 @@ test "placement_get_multi null keys returns invalid_value" {
|
||||
var values = [_]?*anyopaque{@ptrCast(&id)};
|
||||
try testing.expectEqual(Result.invalid_value, placement_get_multi(null, 1, null, &values, null));
|
||||
}
|
||||
|
||||
test "storage generation via get" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
var t: terminal_c.Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
|
||||
));
|
||||
defer terminal_c.free(t);
|
||||
|
||||
var graphics: KittyGraphics = undefined;
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
|
||||
// Fresh storage: generation zero.
|
||||
var gen0: u64 = 99;
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen0)));
|
||||
try testing.expectEqual(0, gen0);
|
||||
|
||||
// Transmit bumps the generation.
|
||||
const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
|
||||
terminal_c.vt_write(t, transmit.ptr, transmit.len);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
var gen1: u64 = 0;
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen1)));
|
||||
try testing.expect(gen1 > gen0);
|
||||
|
||||
// Unrelated terminal writes (plain text) do not bump it.
|
||||
const text = "hello world";
|
||||
terminal_c.vt_write(t, text.ptr, text.len);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
var gen2: u64 = 0;
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen2)));
|
||||
try testing.expectEqual(gen1, gen2);
|
||||
|
||||
// Placement bumps it.
|
||||
const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\";
|
||||
terminal_c.vt_write(t, display.ptr, display.len);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
var gen3: u64 = 0;
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen3)));
|
||||
try testing.expect(gen3 > gen2);
|
||||
|
||||
// Delete bumps it.
|
||||
const del = "\x1b_Ga=d,d=A\x1b\\";
|
||||
terminal_c.vt_write(t, del.ptr, del.len);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
var gen4: u64 = 0;
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen4)));
|
||||
try testing.expect(gen4 > gen3);
|
||||
}
|
||||
|
||||
test "image generation detects same-sized retransmission" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
var t: terminal_c.Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
|
||||
));
|
||||
defer terminal_c.free(t);
|
||||
|
||||
// Transmit a 1x2 RGB image with id=1.
|
||||
const transmit1 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
|
||||
terminal_c.vt_write(t, transmit1.ptr, transmit1.len);
|
||||
|
||||
var graphics: KittyGraphics = undefined;
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
|
||||
var gen1: u64 = 0;
|
||||
var w1: u32 = 0;
|
||||
var h1: u32 = 0;
|
||||
var len1: usize = 0;
|
||||
{
|
||||
const img = image_get_handle(graphics, 1);
|
||||
try testing.expect(img != null);
|
||||
try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen1)));
|
||||
try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w1)));
|
||||
try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h1)));
|
||||
try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len1)));
|
||||
try testing.expect(gen1 > 0);
|
||||
}
|
||||
|
||||
// Retransmit the same ID with identical dimensions but different
|
||||
// pixel bytes. All size heuristics match; only the generation
|
||||
// reveals the change.
|
||||
const transmit2 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\";
|
||||
terminal_c.vt_write(t, transmit2.ptr, transmit2.len);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
|
||||
{
|
||||
const img = image_get_handle(graphics, 1);
|
||||
try testing.expect(img != null);
|
||||
var gen2: u64 = 0;
|
||||
var w2: u32 = 0;
|
||||
var h2: u32 = 0;
|
||||
var len2: usize = 0;
|
||||
try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen2)));
|
||||
try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w2)));
|
||||
try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h2)));
|
||||
try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len2)));
|
||||
|
||||
// Size heuristics are identical...
|
||||
try testing.expectEqual(w1, w2);
|
||||
try testing.expectEqual(h1, h2);
|
||||
try testing.expectEqual(len1, len2);
|
||||
|
||||
// ...but the generation changed.
|
||||
try testing.expect(gen2 > gen1);
|
||||
}
|
||||
}
|
||||
|
||||
test "image generation via image_get_multi" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
var t: terminal_c.Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
|
||||
));
|
||||
defer terminal_c.free(t);
|
||||
|
||||
const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
|
||||
terminal_c.vt_write(t, transmit.ptr, transmit.len);
|
||||
|
||||
var graphics: KittyGraphics = undefined;
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
const img = image_get_handle(graphics, 1);
|
||||
try testing.expect(img != null);
|
||||
|
||||
var id: u32 = 0;
|
||||
var generation: u64 = 0;
|
||||
var written: usize = 0;
|
||||
const keys = [_]ImageData{ .id, .generation };
|
||||
var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&generation) };
|
||||
try testing.expectEqual(Result.success, image_get_multi(img, keys.len, &keys, &values, &written));
|
||||
try testing.expectEqual(keys.len, written);
|
||||
try testing.expectEqual(1, id);
|
||||
try testing.expect(generation > 0);
|
||||
|
||||
// The image stamp came from the same sequence as (and here, the
|
||||
// same event as) the storage stamp.
|
||||
var storage_gen: u64 = 0;
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&storage_gen)));
|
||||
try testing.expectEqual(storage_gen, generation);
|
||||
}
|
||||
|
||||
test "image compression and format always report decoded data" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
var t: terminal_c.Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
|
||||
));
|
||||
defer terminal_c.free(t);
|
||||
|
||||
// Transmit a zlib-compressed 1x1 RGB image (o=z). The payload is
|
||||
// base64(zlib([0xFF, 0x00, 0x00])).
|
||||
const compressed = comptime blk: {
|
||||
// zlib stream for the 3 bytes FF 00 00 (stored block).
|
||||
// 78 01: zlib header; 01: final stored block; 03 00 len;
|
||||
// FC FF nlen; FF 00 00 data; adler32 = 0x03000100 (big endian).
|
||||
const raw = [_]u8{ 0x78, 0x01, 0x01, 0x03, 0x00, 0xFC, 0xFF, 0xFF, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00 };
|
||||
var buf: [std.base64.standard.Encoder.calcSize(raw.len)]u8 = undefined;
|
||||
const enc = std.base64.standard.Encoder.encode(&buf, &raw);
|
||||
break :blk enc[0..enc.len].*;
|
||||
};
|
||||
const transmit = "\x1b_Ga=t,t=d,f=24,o=z,i=1,s=1,v=1;" ++ compressed ++ "\x1b\\";
|
||||
terminal_c.vt_write(t, transmit.ptr, transmit.len);
|
||||
|
||||
var graphics: KittyGraphics = undefined;
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
const img = image_get_handle(graphics, 1);
|
||||
try testing.expect(img != null);
|
||||
|
||||
// Compression must report NONE: the data was inflated at
|
||||
// transmission time.
|
||||
var comp: ImageCompression = undefined;
|
||||
try testing.expectEqual(Result.success, image_get(img, .compression, @ptrCast(&comp)));
|
||||
try testing.expectEqual(.none, comp);
|
||||
|
||||
// Data is the decoded pixels: width * height * bpp.
|
||||
var data_len: usize = 0;
|
||||
try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len)));
|
||||
try testing.expectEqual(3, data_len);
|
||||
|
||||
var data_ptr: [*]const u8 = undefined;
|
||||
try testing.expectEqual(Result.success, image_get(img, .data_ptr, @ptrCast(&data_ptr)));
|
||||
try testing.expectEqualSlices(u8, &.{ 0xFF, 0x00, 0x00 }, data_ptr[0..data_len]);
|
||||
}
|
||||
|
||||
test "generation never recurs across resets and screen switches" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
var t: terminal_c.Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
|
||||
));
|
||||
defer terminal_c.free(t);
|
||||
|
||||
var graphics: KittyGraphics = undefined;
|
||||
var gen: u64 = 0;
|
||||
|
||||
// Transmit on the main screen.
|
||||
const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
|
||||
terminal_c.vt_write(t, transmit.ptr, transmit.len);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
|
||||
const gen_main = gen;
|
||||
try testing.expect(gen_main > 0);
|
||||
|
||||
// Switch to the alternate screen: its storage is untouched, so its
|
||||
// generation is zero (which always means "empty").
|
||||
const alt_on = "\x1b[?1049h";
|
||||
terminal_c.vt_write(t, alt_on.ptr, alt_on.len);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
|
||||
try testing.expectEqual(0, gen);
|
||||
|
||||
// A transmit on the alt screen draws from the same global sequence,
|
||||
// so its stamp is strictly greater than anything seen on the main
|
||||
// screen: an embedder keying a cache on the generation value alone
|
||||
// can never confuse the two storages.
|
||||
terminal_c.vt_write(t, transmit.ptr, transmit.len);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
|
||||
const gen_alt = gen;
|
||||
try testing.expect(gen_alt > gen_main);
|
||||
|
||||
// Back to the main screen: its generation is unchanged.
|
||||
const alt_off = "\x1b[?1049l";
|
||||
terminal_c.vt_write(t, alt_off.ptr, alt_off.len);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
|
||||
try testing.expectEqual(gen_main, gen);
|
||||
|
||||
// Reset zeroes the storage (empty), and the next mutation continues
|
||||
// from the global sequence: past values are never reused for
|
||||
// different content.
|
||||
terminal_c.reset(t);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
|
||||
try testing.expectEqual(0, gen);
|
||||
|
||||
terminal_c.vt_write(t, transmit.ptr, transmit.len);
|
||||
try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
|
||||
try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
|
||||
try testing.expect(gen > gen_alt);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub const LoadingImage = image.LoadingImage;
|
||||
pub const ImageStorage = storage.ImageStorage;
|
||||
pub const RenderPlacement = render.Placement;
|
||||
pub const Response = command.Response;
|
||||
pub const nextGeneration = storage.nextGeneration;
|
||||
|
||||
pub const execute = exec.execute;
|
||||
|
||||
|
||||
@@ -380,7 +380,6 @@ const EncodeableError = Image.Error || Allocator.Error;
|
||||
fn encodeError(r: *Response, err: EncodeableError) void {
|
||||
switch (err) {
|
||||
error.OutOfMemory => r.message = "ENOMEM: out of memory",
|
||||
error.InternalError => r.message = "EINVAL: internal error",
|
||||
error.InvalidData => r.message = "EINVAL: invalid data",
|
||||
error.DecompressionFailed => r.message = "EINVAL: decompression failed",
|
||||
error.FilePathTooLong => r.message = "EINVAL: file path too long",
|
||||
@@ -573,3 +572,87 @@ test "kittygfx no response with no image ID or number load and display" {
|
||||
try testing.expect(resp == null);
|
||||
}
|
||||
}
|
||||
|
||||
test "kittygfx retransmit same id gets fresh image generation" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
|
||||
defer t.deinit(alloc);
|
||||
const storage = &t.screens.active.kitty_images;
|
||||
|
||||
// Transmit a 1x2 RGB image with id=1.
|
||||
{
|
||||
const cmd = try command.Parser.parseString(
|
||||
alloc,
|
||||
"a=t,t=d,f=24,i=1,s=1,v=2;////////",
|
||||
);
|
||||
defer cmd.deinit(alloc);
|
||||
const resp = execute(alloc, &t, &cmd).?;
|
||||
try testing.expect(resp.ok());
|
||||
}
|
||||
const gen1 = storage.imageById(1).?.generation;
|
||||
try testing.expect(gen1 > 0);
|
||||
try testing.expectEqual(gen1, storage.generation);
|
||||
|
||||
// Retransmit the same id with identical dimensions/length. The
|
||||
// (width, height, format, len) tuple is identical, so only the
|
||||
// generation can reveal that the contents were replaced.
|
||||
{
|
||||
const cmd = try command.Parser.parseString(
|
||||
alloc,
|
||||
"a=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA",
|
||||
);
|
||||
defer cmd.deinit(alloc);
|
||||
const resp = execute(alloc, &t, &cmd).?;
|
||||
try testing.expect(resp.ok());
|
||||
}
|
||||
const gen2 = storage.imageById(1).?.generation;
|
||||
try testing.expect(gen2 > gen1);
|
||||
try testing.expectEqual(gen2, storage.generation);
|
||||
}
|
||||
|
||||
test "kittygfx delete then retransmit same id gets fresh generation" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
|
||||
defer t.deinit(alloc);
|
||||
const storage = &t.screens.active.kitty_images;
|
||||
|
||||
// Transmit and display, then delete everything (including image
|
||||
// data), then retransmit the same ID.
|
||||
{
|
||||
const cmd = try command.Parser.parseString(
|
||||
alloc,
|
||||
"a=T,t=d,f=24,i=1,s=1,v=2,c=1,r=1;////////",
|
||||
);
|
||||
defer cmd.deinit(alloc);
|
||||
const resp = execute(alloc, &t, &cmd).?;
|
||||
try testing.expect(resp.ok());
|
||||
}
|
||||
const gen1 = storage.imageById(1).?.generation;
|
||||
|
||||
{
|
||||
const cmd = try command.Parser.parseString(alloc, "a=d,d=A");
|
||||
defer cmd.deinit(alloc);
|
||||
const resp = execute(alloc, &t, &cmd);
|
||||
try testing.expect(resp == null);
|
||||
}
|
||||
try testing.expect(storage.imageById(1) == null);
|
||||
const gen_delete = storage.generation;
|
||||
try testing.expect(gen_delete > gen1);
|
||||
|
||||
{
|
||||
const cmd = try command.Parser.parseString(
|
||||
alloc,
|
||||
"a=t,t=d,f=24,i=1,s=1,v=2;////////",
|
||||
);
|
||||
defer cmd.deinit(alloc);
|
||||
const resp = execute(alloc, &t, &cmd).?;
|
||||
try testing.expect(resp.ok());
|
||||
}
|
||||
const gen2 = storage.imageById(1).?.generation;
|
||||
try testing.expect(gen2 > gen1);
|
||||
try testing.expect(gen2 > gen_delete);
|
||||
}
|
||||
|
||||
@@ -401,12 +401,6 @@ pub const LoadingImage = struct {
|
||||
return error.InvalidData;
|
||||
}
|
||||
|
||||
// Set our time
|
||||
self.image.transmit_time = std.time.Instant.now() catch |err| {
|
||||
log.warn("failed to get time: {}", .{err});
|
||||
return error.InternalError;
|
||||
};
|
||||
|
||||
// Everything looks good, copy the image data over.
|
||||
var result = self.image;
|
||||
result.data = try self.data.toOwnedSlice(alloc);
|
||||
@@ -504,6 +498,12 @@ pub const LoadingImage = struct {
|
||||
};
|
||||
|
||||
/// Image represents a single fully loaded image.
|
||||
///
|
||||
/// The image data is always fully decoded raw pixels: loading inflates
|
||||
/// any zlib-compressed payload and decodes PNG into RGBA before an image
|
||||
/// is completed, so `compression` is always `.none` and `format` is
|
||||
/// never `.png` for a stored image, and `data.len` always equals
|
||||
/// `width * height * bytes-per-pixel`.
|
||||
pub const Image = struct {
|
||||
id: u32 = 0,
|
||||
number: u32 = 0,
|
||||
@@ -512,7 +512,14 @@ pub const Image = struct {
|
||||
format: command.Transmission.Format = .rgb,
|
||||
compression: command.Transmission.Compression = .none,
|
||||
data: []const u8 = "",
|
||||
transmit_time: std.time.Instant = undefined,
|
||||
|
||||
/// Unique, monotonically increasing stamp assigned each time an
|
||||
/// image is added to (or replaced in) an ImageStorage. A changed
|
||||
/// generation for a given image ID means the image contents may
|
||||
/// have changed, even if the dimensions and byte length are the
|
||||
/// same (e.g. a retransmission of the same ID). Stamps order by
|
||||
/// transmission time. Zero means "never stored".
|
||||
generation: u64 = 0,
|
||||
|
||||
/// Set this to true if this image was loaded by a command that
|
||||
/// doesn't specify an ID or number, since such commands should
|
||||
@@ -521,7 +528,6 @@ pub const Image = struct {
|
||||
implicit_id: bool = false,
|
||||
|
||||
pub const Error = error{
|
||||
InternalError,
|
||||
InvalidData,
|
||||
DecompressionFailed,
|
||||
DimensionsRequired,
|
||||
|
||||
@@ -16,6 +16,26 @@ const Command = command.Command;
|
||||
|
||||
const log = std.log.scoped(.kitty_gfx);
|
||||
|
||||
/// Process-global counter backing all generation stamps (see
|
||||
/// ImageStorage.generation and Image.generation). This is global rather
|
||||
/// than per-storage so that stamps are unique across every storage in
|
||||
/// the process: two mutation events never produce the same value, even
|
||||
/// across separate screens (main vs. alt), storage resets, or separate
|
||||
/// terminals. This lets consumers use a generation value alone as a
|
||||
/// cache key without any ambiguity.
|
||||
///
|
||||
/// Atomic because separate terminals may mutate their storages from
|
||||
/// different threads. On single-threaded targets (e.g. wasm without
|
||||
/// atomics) this lowers to plain operations.
|
||||
var generation_counter: std.atomic.Value(u64) = .init(0);
|
||||
|
||||
/// Returns the next generation stamp. Stamps are unique and strictly
|
||||
/// monotonically increasing process-wide, starting at 1 (0 is reserved
|
||||
/// to mean "never stamped").
|
||||
pub fn nextGeneration() u64 {
|
||||
return generation_counter.fetchAdd(1, .monotonic) + 1;
|
||||
}
|
||||
|
||||
/// An image storage is associated with a terminal screen (i.e. main
|
||||
/// screen, alt screen) and contains all the transmitted images and
|
||||
/// placements.
|
||||
@@ -27,8 +47,32 @@ pub const ImageStorage = struct {
|
||||
/// purely informational for the renderer and doesn't affect the
|
||||
/// correctness of the program. The renderer must set this to false
|
||||
/// if it cares about this value.
|
||||
///
|
||||
/// Note that dirty is also set by scrolling and resizing (outside
|
||||
/// of this struct) because those move placement pins, even though
|
||||
/// the set of images/placements itself is unchanged. See generation
|
||||
/// for a signal that only tracks content mutations.
|
||||
///
|
||||
/// Invariant: dirty is always set when generation changes
|
||||
/// (markMutated sets both); dirty set without a generation change
|
||||
/// means a geometry-only event.
|
||||
dirty: bool = false,
|
||||
|
||||
/// Generation stamp of the last content mutation to this storage:
|
||||
/// any image transmit/replace, placement add, or delete of either.
|
||||
/// Zero means the storage has never been mutated (and is therefore
|
||||
/// empty).
|
||||
///
|
||||
/// Unlike dirty, this is NOT updated by scrolling/resizing, so an
|
||||
/// unchanged generation means the placement set and all image data
|
||||
/// are identical; only placement geometry (pins) may have moved.
|
||||
/// Values come from a process-global monotonic counter, so a value
|
||||
/// observed from any storage never recurs for different content,
|
||||
/// even across screen switches or storage resets.
|
||||
///
|
||||
/// This field must only be written via markMutated.
|
||||
generation: u64 = 0,
|
||||
|
||||
/// This is the next automatically assigned image ID. We start mid-way
|
||||
/// through the u32 range to avoid collisions with buggy programs.
|
||||
/// TODO: This isn't good enough, it's perfectly legal for programs
|
||||
@@ -80,6 +124,19 @@ pub const ImageStorage = struct {
|
||||
return self.total_limit != 0;
|
||||
}
|
||||
|
||||
/// Record a content mutation: marks the storage dirty and assigns a
|
||||
/// fresh generation stamp. Must be called by anything that changes
|
||||
/// the set of images or placements (or image contents).
|
||||
///
|
||||
/// Do NOT call this for geometry-only events (scrolling, resizing,
|
||||
/// screen switches); those must set only the dirty flag directly.
|
||||
/// Bumping the generation for geometry changes would break the
|
||||
/// contract that an unchanged generation means unchanged contents.
|
||||
fn markMutated(self: *ImageStorage) void {
|
||||
self.dirty = true;
|
||||
self.generation = nextGeneration();
|
||||
}
|
||||
|
||||
/// Sets the limit in bytes for the total amount of image data that
|
||||
/// can be loaded. If this limit is lower, this will do an eviction
|
||||
/// if necessary. If the value is zero, then Kitty image protocol will
|
||||
@@ -95,6 +152,7 @@ pub const ImageStorage = struct {
|
||||
const image_limits = self.image_limits;
|
||||
self.deinit(alloc, s);
|
||||
self.* = .{ .image_limits = image_limits };
|
||||
self.markMutated();
|
||||
}
|
||||
|
||||
// If we re lowering our limit, check if we need to evict.
|
||||
@@ -144,7 +202,12 @@ pub const ImageStorage = struct {
|
||||
gop.value_ptr.* = img;
|
||||
self.total_bytes += img.data.len;
|
||||
|
||||
self.dirty = true;
|
||||
// Stamp the stored image with a fresh generation. This gives
|
||||
// every add/replace a unique stamp even when the same image ID
|
||||
// is retransmitted with identical dimensions, so consumers
|
||||
// (e.g. renderer texture caches) can detect content changes.
|
||||
self.markMutated();
|
||||
gop.value_ptr.generation = self.generation;
|
||||
}
|
||||
|
||||
/// Add a placement for a given image. The caller must verify in advance
|
||||
@@ -185,7 +248,7 @@ pub const ImageStorage = struct {
|
||||
const gop = try self.placements.getOrPut(alloc, key);
|
||||
gop.value_ptr.* = p;
|
||||
|
||||
self.dirty = true;
|
||||
self.markMutated();
|
||||
}
|
||||
|
||||
fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void {
|
||||
@@ -207,7 +270,7 @@ pub const ImageStorage = struct {
|
||||
while (it.next()) |kv| {
|
||||
if (kv.value_ptr.number == image_number) {
|
||||
if (newest == null or
|
||||
kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt)
|
||||
kv.value_ptr.generation > newest.?.generation)
|
||||
{
|
||||
newest = kv.value_ptr.*;
|
||||
}
|
||||
@@ -224,6 +287,17 @@ pub const ImageStorage = struct {
|
||||
t: *terminal.Terminal,
|
||||
cmd: command.Delete,
|
||||
) void {
|
||||
// Deletes only ever remove placements/images, so comparing counts
|
||||
// before and after tells us whether anything actually changed.
|
||||
// Only then do we mark a mutation. This matters because a
|
||||
// delete-all runs on every screen clear (e.g. `ESC [ 2 J`), and
|
||||
// we don't want empty clears to dirty the image state or bump
|
||||
// the generation.
|
||||
const placements_before = self.placements.count();
|
||||
const images_before = self.images.count();
|
||||
defer if (self.placements.count() != placements_before or
|
||||
self.images.count() != images_before) self.markMutated();
|
||||
|
||||
switch (cmd) {
|
||||
.all => |delete_images| {
|
||||
var it = self.placements.iterator();
|
||||
@@ -245,8 +319,6 @@ pub const ImageStorage = struct {
|
||||
var image_it = self.images.iterator();
|
||||
while (image_it.next()) |kv| self.deleteIfUnused(alloc, kv.key_ptr.*);
|
||||
}
|
||||
|
||||
self.dirty = true;
|
||||
},
|
||||
|
||||
.id => |v| self.deleteById(
|
||||
@@ -341,9 +413,6 @@ pub const ImageStorage = struct {
|
||||
if (v.delete) self.deleteIfUnused(alloc, img.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
},
|
||||
|
||||
.row => |v| row: {
|
||||
@@ -373,9 +442,6 @@ pub const ImageStorage = struct {
|
||||
if (v.delete) self.deleteIfUnused(alloc, img.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
},
|
||||
|
||||
.z => |v| {
|
||||
@@ -396,9 +462,6 @@ pub const ImageStorage = struct {
|
||||
if (v.delete) self.deleteIfUnused(alloc, image_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
},
|
||||
|
||||
.range => |v| range: {
|
||||
@@ -420,9 +483,6 @@ pub const ImageStorage = struct {
|
||||
if (v.delete) self.deleteIfUnused(alloc, image_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
},
|
||||
|
||||
// We don't support animation frames yet so they are successfully
|
||||
@@ -461,9 +521,6 @@ pub const ImageStorage = struct {
|
||||
// If this is specified, then we also delete the image
|
||||
// if it is no longer in use.
|
||||
if (delete_unused) self.deleteIfUnused(alloc, image_id);
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Delete an image if it is unused.
|
||||
@@ -507,9 +564,6 @@ pub const ImageStorage = struct {
|
||||
if (delete_unused) self.deleteIfUnused(alloc, img.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Evict image to make space. This will evict the oldest image,
|
||||
@@ -526,7 +580,7 @@ pub const ImageStorage = struct {
|
||||
// bit is fine compared to the megabytes we're looking to save.
|
||||
const Candidate = struct {
|
||||
id: u32,
|
||||
time: std.time.Instant,
|
||||
generation: u64,
|
||||
used: bool,
|
||||
};
|
||||
|
||||
@@ -554,7 +608,7 @@ pub const ImageStorage = struct {
|
||||
|
||||
try candidates.append(alloc, .{
|
||||
.id = img.id,
|
||||
.time = img.transmit_time,
|
||||
.generation = img.generation,
|
||||
.used = used,
|
||||
});
|
||||
}
|
||||
@@ -572,12 +626,14 @@ pub const ImageStorage = struct {
|
||||
) bool {
|
||||
_ = ctx;
|
||||
|
||||
// If they're usage matches, then its based on time.
|
||||
if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) {
|
||||
.lt => true,
|
||||
.gt => false,
|
||||
.eq => lhs.id < rhs.id,
|
||||
};
|
||||
// If their usage matches, then it's based on the
|
||||
// generation stamp, which orders by transmit time.
|
||||
// (Stamps are unique but tie-break by ID anyway to
|
||||
// stay deterministic for hand-built test images.)
|
||||
if (lhs.used == rhs.used) return if (lhs.generation == rhs.generation)
|
||||
lhs.id < rhs.id
|
||||
else
|
||||
lhs.generation < rhs.generation;
|
||||
|
||||
// If not used, then its a better candidate
|
||||
return !lhs.used;
|
||||
@@ -585,6 +641,11 @@ pub const ImageStorage = struct {
|
||||
}.lessThan,
|
||||
);
|
||||
|
||||
// Evicting anything is a content mutation. This matters for the
|
||||
// setLimit path in particular, which doesn't otherwise mark it.
|
||||
var any_evicted = false;
|
||||
defer if (any_evicted) self.markMutated();
|
||||
|
||||
// They're in order of best to evict.
|
||||
var evicted: usize = 0;
|
||||
for (candidates.items) |c| {
|
||||
@@ -593,6 +654,7 @@ pub const ImageStorage = struct {
|
||||
while (p_it.next()) |entry| {
|
||||
if (entry.key_ptr.image_id == c.id) {
|
||||
self.placements.removeByPtr(entry.key_ptr);
|
||||
any_evicted = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,6 +666,7 @@ pub const ImageStorage = struct {
|
||||
|
||||
entry.value_ptr.deinit(alloc);
|
||||
self.images.removeByPtr(entry.key_ptr);
|
||||
any_evicted = true;
|
||||
|
||||
if (evicted > req) return true;
|
||||
}
|
||||
@@ -1362,3 +1425,150 @@ test "storage: aspect ratio calculation when only columns or rows specified" {
|
||||
try testing.expectEqual(@as(u32, 100), calc_size.height);
|
||||
}
|
||||
}
|
||||
|
||||
test "storage: generation stamps on image add and replace" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, t.screens.active);
|
||||
|
||||
// Fresh storage has generation zero (never mutated).
|
||||
try testing.expectEqual(@as(u64, 0), s.generation);
|
||||
|
||||
try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 });
|
||||
const gen1 = s.generation;
|
||||
try testing.expect(gen1 > 0);
|
||||
|
||||
const img1 = s.imageById(1).?;
|
||||
try testing.expectEqual(gen1, img1.generation);
|
||||
|
||||
// A second image gets a strictly greater stamp.
|
||||
try s.addImage(alloc, .{ .id = 2, .width = 1, .height = 1 });
|
||||
const gen2 = s.generation;
|
||||
try testing.expect(gen2 > gen1);
|
||||
try testing.expectEqual(gen2, s.imageById(2).?.generation);
|
||||
|
||||
// Retransmitting the same image ID (identical dimensions) gets a
|
||||
// fresh stamp: this is what makes same-sized retransmissions
|
||||
// detectable by renderers.
|
||||
try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 });
|
||||
const gen3 = s.generation;
|
||||
try testing.expect(gen3 > gen2);
|
||||
try testing.expectEqual(gen3, s.imageById(1).?.generation);
|
||||
|
||||
// Image 2 kept its stamp.
|
||||
try testing.expectEqual(gen2, s.imageById(2).?.generation);
|
||||
}
|
||||
|
||||
test "storage: generation bumps on placement and delete" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, t.screens.active);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
const gen_add = s.generation;
|
||||
|
||||
try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } });
|
||||
const gen_place = s.generation;
|
||||
try testing.expect(gen_place > gen_add);
|
||||
|
||||
// Reads don't change the generation.
|
||||
_ = s.imageById(1);
|
||||
_ = s.imageByNumber(1);
|
||||
try testing.expectEqual(gen_place, s.generation);
|
||||
|
||||
s.delete(alloc, &t, .{ .all = true });
|
||||
try testing.expect(s.generation > gen_place);
|
||||
}
|
||||
|
||||
test "storage: generation bumps when setLimit evicts or disables" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, t.screens.active);
|
||||
|
||||
const data = try alloc.dupe(u8, "1234");
|
||||
try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1, .data = data });
|
||||
const gen_add = s.generation;
|
||||
|
||||
// Lowering the limit evicts the image and must mark a mutation.
|
||||
s.dirty = false;
|
||||
try s.setLimit(alloc, t.screens.active, 1);
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expect(s.generation > gen_add);
|
||||
try testing.expectEqual(@as(usize, 0), s.images.count());
|
||||
const gen_evict = s.generation;
|
||||
|
||||
// Disabling (limit=0) resets the storage and must mark a mutation.
|
||||
s.dirty = false;
|
||||
try s.setLimit(alloc, t.screens.active, 0);
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expect(s.generation > gen_evict);
|
||||
}
|
||||
|
||||
test "storage: imageByNumber returns most recently transmitted" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, t.screens.active);
|
||||
|
||||
// Two images sharing a number: the newest transmission wins,
|
||||
// regardless of insertion order or clock resolution.
|
||||
try s.addImage(alloc, .{ .id = 1, .number = 7 });
|
||||
try s.addImage(alloc, .{ .id = 2, .number = 7 });
|
||||
try testing.expectEqual(@as(u32, 2), s.imageByNumber(7).?.id);
|
||||
|
||||
// Retransmit the first: it becomes the newest.
|
||||
try s.addImage(alloc, .{ .id = 1, .number = 7 });
|
||||
try testing.expectEqual(@as(u32, 1), s.imageByNumber(7).?.id);
|
||||
}
|
||||
|
||||
test "storage: nextGeneration is unique and monotonic" {
|
||||
const testing = std.testing;
|
||||
const a = nextGeneration();
|
||||
const b = nextGeneration();
|
||||
try testing.expect(b > a);
|
||||
try testing.expect(a > 0);
|
||||
}
|
||||
|
||||
test "storage: no-op delete does not mark a mutation" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, t.screens.active);
|
||||
|
||||
// A delete-all on an empty storage (this runs on every screen
|
||||
// clear) must not dirty the state or bump the generation.
|
||||
s.delete(alloc, &t, .{ .all = true });
|
||||
try testing.expect(!s.dirty);
|
||||
try testing.expectEqual(@as(u64, 0), s.generation);
|
||||
|
||||
// Same for a delete that matches nothing.
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } });
|
||||
const gen = s.generation;
|
||||
s.dirty = false;
|
||||
s.delete(alloc, &t, .{ .id = .{ .image_id = 42 } });
|
||||
try testing.expect(!s.dirty);
|
||||
try testing.expectEqual(gen, s.generation);
|
||||
|
||||
// But a delete that removes something does mark a mutation.
|
||||
s.delete(alloc, &t, .{ .id = .{ .image_id = 1 } });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expect(s.generation > gen);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user