diff --git a/AGENTS.md b/AGENTS.md index f4c4db7a9..7098007f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ A file for [guiding coding agents](https://agents.md/). - Build: `zig build -Demit-lib-vt` - Build WASM: `zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall` +- Test: `zig build test-lib-vt -Dtest-filter=` + - Prefer this when the change is in a libghostty-vt file ## Directory Structure diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index e31bb2c65..446834d18 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -138,6 +138,38 @@ typedef enum { GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z = 12, } GhosttyKittyGraphicsPlacementData; +/** + * Z-layer classification for kitty graphics placements. + * + * Based on the kitty protocol z-index conventions: + * - BELOW_BG: z < INT32_MIN/2 (drawn below cell background) + * - BELOW_TEXT: INT32_MIN/2 <= z < 0 (above background, below text) + * - ABOVE_TEXT: z >= 0 (above text) + * - ALL: no filtering (current behavior) + * + * @ingroup kitty_graphics + */ +typedef enum { + GHOSTTY_KITTY_PLACEMENT_LAYER_ALL = 0, + GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_BG = 1, + GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT = 2, + GHOSTTY_KITTY_PLACEMENT_LAYER_ABOVE_TEXT = 3, +} GhosttyKittyPlacementLayer; + +/** + * Settable options for ghostty_kitty_graphics_placement_iterator_set(). + * + * @ingroup kitty_graphics + */ +typedef enum { + /** + * Set the z-layer filter for the iterator. + * + * Input type: GhosttyKittyPlacementLayer * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER = 0, +} GhosttyKittyGraphicsPlacementIteratorOption; + /** * Pixel format of a Kitty graphics image. * @@ -310,9 +342,36 @@ GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_new( GHOSTTY_API void ghostty_kitty_graphics_placement_iterator_free( GhosttyKittyGraphicsPlacementIterator iterator); +/** + * Set an option on a placement iterator. + * + * Use GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER with a + * GhosttyKittyPlacementLayer value to filter placements by z-layer. + * The filter is applied during iteration: ghostty_kitty_graphics_placement_next() + * will skip placements that do not match the configured layer. + * + * The default layer is GHOSTTY_KITTY_PLACEMENT_LAYER_ALL (no filtering). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param value Pointer to the value (type depends on option; NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_set( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsPlacementIteratorOption option, + const void* value); + /** * Advance the placement iterator to the next placement. * + * If a layer filter has been set via + * ghostty_kitty_graphics_placement_iterator_set(), only placements + * matching that layer are returned. + * * @param iterator The iterator handle (may be NULL) * @return true if advanced to the next placement, false if at the end * @@ -415,6 +474,75 @@ GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_grid_size( uint32_t* out_cols, uint32_t* out_rows); +/** + * Get the viewport-relative grid position of the current placement. + * + * Converts the placement's internal pin to viewport-relative column and + * row coordinates. The returned coordinates represent the top-left + * corner of the placement in the viewport's grid coordinate space. + * + * The row value can be negative when the placement's origin has + * scrolled above the top of the viewport. For example, a 4-row + * image that has scrolled up by 2 rows returns row=-2, meaning + * its top 2 rows are above the visible area but its bottom 2 rows + * are still on screen. Embedders should use these coordinates + * directly when computing the destination rectangle for rendering; + * the embedder is responsible for clipping the portion of the image + * that falls outside the viewport. + * + * Returns GHOSTTY_SUCCESS for any placement that is at least + * partially visible in the viewport. Returns GHOSTTY_NO_VALUE when + * the placement is completely outside the viewport (its bottom edge + * is above the viewport or its top edge is at or below the last + * viewport row), or when the placement is a virtual (unicode + * placeholder) placement. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_col On success, receives the viewport-relative column + * @param[out] out_row On success, receives the viewport-relative row + * (may be negative for partially visible placements) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if fully + * off-screen or virtual, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_viewport_pos( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + int32_t* out_col, + int32_t* out_row); + +/** + * Get the resolved source rectangle for the current placement. + * + * Applies kitty protocol semantics: a width or height of 0 in the + * placement means "use the full image dimension", and the resulting + * rectangle is clamped to the actual image bounds. The returned + * values are in pixels and are ready to use for texture sampling. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param[out] out_x Source rect x origin in pixels + * @param[out] out_y Source rect y origin in pixels + * @param[out] out_width Source rect width in pixels + * @param[out] out_height Source rect height in pixels + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any + * handle is NULL or the iterator is not positioned + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_source_rect( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + uint32_t* out_x, + uint32_t* out_y, + uint32_t* out_width, + uint32_t* out_height); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 3799fbe66..ff11177da 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -239,11 +239,14 @@ comptime { @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" }); @export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" }); @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" }); + @export(&c.kitty_graphics_placement_iterator_set, .{ .name = "ghostty_kitty_graphics_placement_iterator_set" }); @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); @export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" }); @export(&c.kitty_graphics_placement_rect, .{ .name = "ghostty_kitty_graphics_placement_rect" }); @export(&c.kitty_graphics_placement_pixel_size, .{ .name = "ghostty_kitty_graphics_placement_pixel_size" }); @export(&c.kitty_graphics_placement_grid_size, .{ .name = "ghostty_kitty_graphics_placement_grid_size" }); + @export(&c.kitty_graphics_placement_viewport_pos, .{ .name = "ghostty_kitty_graphics_placement_viewport_pos" }); + @export(&c.kitty_graphics_placement_source_rect, .{ .name = "ghostty_kitty_graphics_placement_source_rect" }); @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index f5811a024..a086f8e9f 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -42,6 +42,7 @@ const PlacementIteratorWrapper = if (build_options.kitty_graphics) alloc: std.mem.Allocator, inner: PlacementMap.Iterator = undefined, entry: ?PlacementMap.Entry = null, + layer_filter: PlacementLayer = .all, } else void; @@ -124,12 +125,41 @@ fn getTyped( it.* = .{ .alloc = it.alloc, .inner = storage.placements.iterator(), + .layer_filter = it.layer_filter, }; }, } return .success; } +/// C: GhosttyKittyPlacementLayer +pub const PlacementLayer = enum(c_int) { + all = 0, + below_bg = 1, + below_text = 2, + above_text = 3, + + fn matches(self: PlacementLayer, z: i32) bool { + return switch (self) { + .all => true, + .below_bg => z < std.math.minInt(i32) / 2, + .below_text => z >= std.math.minInt(i32) / 2 and z < 0, + .above_text => z >= 0, + }; + } +}; + +/// C: GhosttyKittyGraphicsPlacementIteratorOption +pub const PlacementIteratorOption = enum(c_int) { + layer = 0, + + pub fn InType(comptime self: PlacementIteratorOption) type { + return switch (self) { + .layer => PlacementLayer, + }; + } +}; + /// C: GhosttyKittyImageFormat pub const ImageFormat = kitty_cmd.Transmission.Format; @@ -233,12 +263,51 @@ pub fn placement_iterator_free(iter_: PlacementIterator) callconv(lib.calling_co iter.alloc.destroy(iter); } +pub fn placement_iterator_set( + iter_: PlacementIterator, + option: PlacementIteratorOption, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(PlacementIteratorOption, @intFromEnum(option)) catch { + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| placementIteratorSetTyped( + iter_, + comptime_option, + @ptrCast(@alignCast(value orelse return .invalid_value)), + ), + }; +} + +fn placementIteratorSetTyped( + iter_: PlacementIterator, + comptime option: PlacementIteratorOption, + value: *const option.InType(), +) Result { + const iter = iter_ orelse return .invalid_value; + switch (option) { + .layer => iter.layer_filter = value.*, + } + return .success; +} + pub fn placement_iterator_next(iter_: PlacementIterator) callconv(lib.calling_conv) bool { if (comptime !build_options.kitty_graphics) return false; const iter = iter_ orelse return false; - iter.entry = iter.inner.next() orelse return false; - return true; + while (iter.inner.next()) |entry| { + if (iter.layer_filter.matches(entry.value_ptr.z)) { + iter.entry = entry; + return true; + } + } + return false; } pub fn placement_get( @@ -353,6 +422,81 @@ pub fn placement_grid_size( return .success; } +pub fn placement_viewport_pos( + iter_: PlacementIterator, + image_: ImageHandle, + terminal_: terminal_c.Terminal, + out_col: *i32, + out_row: *i32, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const wrapper = terminal_ orelse return .invalid_value; + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const pin = switch (entry.value_ptr.location) { + .pin => |p| p, + .virtual => return .no_value, + }; + + const pages = &wrapper.terminal.screens.active.pages; + + // Get screen-absolute coordinates for both the pin and the + // viewport origin, then subtract to get viewport-relative + // coordinates that can be negative for partially visible + // placements above the viewport. + const pin_screen = pages.pointFromPin(.screen, pin.*) orelse return .no_value; + const vp_tl = pages.getTopLeft(.viewport); + const vp_screen = pages.pointFromPin(.screen, vp_tl) orelse return .no_value; + + const vp_row: i32 = @as(i32, @intCast(pin_screen.screen.y)) - + @as(i32, @intCast(vp_screen.screen.y)); + const vp_col: i32 = @intCast(pin_screen.screen.x); + + // Check if the placement is fully off-screen. A placement is + // invisible if its bottom edge is above the viewport or its + // top edge is at or below the viewport's last row. + const grid_size = entry.value_ptr.gridSize(image.*, wrapper.terminal); + const rows_i32: i32 = @intCast(grid_size.rows); + const term_rows: i32 = @intCast(wrapper.terminal.rows); + if (vp_row + rows_i32 <= 0 or vp_row >= term_rows) return .no_value; + + out_col.* = vp_col; + out_row.* = vp_row; + + return .success; +} + +pub fn placement_source_rect( + iter_: PlacementIterator, + image_: ImageHandle, + out_x: *u32, + out_y: *u32, + out_width: *u32, + out_height: *u32, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const p = entry.value_ptr; + + // Apply "0 = full image dimension" convention, then clamp to image bounds. + const x = @min(p.source_x, image.width); + const y = @min(p.source_y, image.height); + const w = @min(if (p.source_width > 0) p.source_width else image.width, image.width - x); + const h = @min(if (p.source_height > 0) p.source_height else image.height, image.height - y); + + out_x.* = x; + out_y.* = y; + out_width.* = w; + out_height.* = h; + + return .success; +} + test "placement_iterator new/free" { var iter: PlacementIterator = null; try testing.expectEqual(Result.success, placement_iterator_new( @@ -537,6 +681,96 @@ test "placement_iterator with multiple placements" { try testing.expect(seen_p2); } +test "placement_iterator_set layer filter" { + 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 image 1. + 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); + + // Display with z=5 (above text), z=-1 (below text), z=-1073741825 (below bg). + // INT32_MIN/2 = -1073741824, so -1073741825 < INT32_MIN/2. + const d1 = "\x1b_Ga=p,i=1,p=1,z=5;\x1b\\"; + const d2 = "\x1b_Ga=p,i=1,p=2,z=-1;\x1b\\"; + const d3 = "\x1b_Ga=p,i=1,p=3,z=-1073741825;\x1b\\"; + terminal_c.vt_write(t, d1.ptr, d1.len); + terminal_c.vt_write(t, d2.ptr, d2.len); + terminal_c.vt_write(t, d3.ptr, d3.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + // Filter: above_text (z >= 0) — should yield only p=1. + var layer = PlacementLayer.above_text; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + var count: u32 = 0; + while (placement_iterator_next(iter)) { + var z: i32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z))); + try testing.expect(z >= 0); + count += 1; + } + try testing.expectEqual(1, count); + + // Filter: below_text (INT32_MIN/2 <= z < 0) — should yield only p=2. + layer = .below_text; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + count = 0; + while (placement_iterator_next(iter)) { + var z: i32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z))); + try testing.expect(z >= std.math.minInt(i32) / 2 and z < 0); + count += 1; + } + try testing.expectEqual(1, count); + + // Filter: below_bg (z < INT32_MIN/2) — should yield only p=3. + layer = .below_bg; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + count = 0; + while (placement_iterator_next(iter)) { + var z: i32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z))); + try testing.expect(z < std.math.minInt(i32) / 2); + count += 1; + } + try testing.expectEqual(1, count); + + // Filter: all — should yield all 3. + layer = .all; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + count = 0; + while (placement_iterator_next(iter)) count += 1; + try testing.expectEqual(3, count); +} + test "image_get_handle returns null for missing id" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; @@ -778,6 +1012,361 @@ test "placement_grid_size null args return invalid_value" { try testing.expectEqual(Result.invalid_value, placement_grid_size(null, null, null, &cols, &rows)); } +test "placement_viewport_pos with transmit and display" { + 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); + + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display at cursor (0,0). + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.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 iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + + try testing.expectEqual(0, col); + try testing.expectEqual(0, row); +} + +test "placement_viewport_pos fully off-screen above" { + 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 = 5, .max_scrollback = 100 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, then display at cursor (0,0) spanning 1 row. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + // Scroll the image completely off: 10 newlines in a 5-row terminal + // scrolls by 5+ rows, so a 1-row image at row 0 is fully gone. + const scroll = "\n\n\n\n\n\n\n\n\n\n"; + terminal_c.vt_write(t, scroll.ptr, scroll.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 iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.no_value, placement_viewport_pos(iter, img, t, &col, &row)); +} + +test "placement_viewport_pos top off-screen" { + 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 = 5, .max_scrollback = 100 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, display at cursor (0,0) spanning 4 rows. + // C=1 prevents cursor movement after display. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4,C=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + // Scroll by 2: cursor starts at row 0, 4 newlines to reach bottom, + // then 2 more to scroll by 2. Image top-left moves to vp_row=-2, + // but bottom rows -2+4=2 > 0 so it's still partially visible. + const scroll = "\n\n\n\n\n\n"; + terminal_c.vt_write(t, scroll.ptr, scroll.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 iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + try testing.expectEqual(0, col); + try testing.expectEqual(-2, row); +} + +test "placement_viewport_pos bottom off-screen" { + 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 = 5, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, move cursor to row 3 (1-based: row 4), display spanning 4 rows. + // C=1 prevents cursor movement after display. + // Image occupies rows 3-6 but viewport only has rows 0-4, so bottom is clipped. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const cursor = "\x1b[4;1H"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4,C=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, cursor.ptr, cursor.len); + terminal_c.vt_write(t, display.ptr, display.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 iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + try testing.expectEqual(0, col); + try testing.expectEqual(3, row); +} + +test "placement_viewport_pos top and bottom off-screen" { + 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 = 5, .max_scrollback = 100 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, display at cursor (0,0) spanning 10 rows. + // C=1 prevents cursor movement after display. + // After scrolling by 3, image occupies vp rows -3..6, viewport is 0..4, + // so both top and bottom are clipped but center is visible. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=10,C=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + // Scroll by 3: 4 newlines to reach bottom + 3 more to scroll. + const scroll = "\n\n\n\n\n\n\n"; + terminal_c.vt_write(t, scroll.ptr, scroll.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 iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + try testing.expectEqual(0, col); + try testing.expectEqual(-3, row); +} + +test "placement_viewport_pos null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_viewport_pos(null, null, null, &col, &row)); +} + +test "placement_source_rect defaults to full image" { + 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); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display a 1x2 RGB image with no source rect specified. + // source_width=0 and source_height=0 should resolve to full image (1x2). + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.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 iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h)); + try testing.expectEqual(0, x); + try testing.expectEqual(0, y); + try testing.expectEqual(1, w); + try testing.expectEqual(2, h); +} + +test "placement_source_rect with explicit source rect" { + 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); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit a 4x4 RGBA image (64 bytes = 4*4*4). + // Base64 of 64 zero bytes: 88 chars (21 full groups + AA== padding). + const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" ++ + "\x1b\\"; + // Display with explicit source rect: x=1, y=1, w=2, h=2. + const display = "\x1b_Ga=p,i=1,p=1,x=1,y=1,w=2,h=2;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.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 iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h)); + try testing.expectEqual(1, x); + try testing.expectEqual(1, y); + try testing.expectEqual(2, w); + try testing.expectEqual(2, h); +} + +test "placement_source_rect clamps to image bounds" { + 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); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit a 4x4 RGBA image (64 bytes = 4*4*4). + const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" ++ + "\x1b\\"; + // Display with source rect that exceeds image bounds: x=3, y=3, w=10, h=10. + // Should clamp to x=3, y=3, w=1, h=1. + const display = "\x1b_Ga=p,i=1,p=1,x=3,y=3,w=10,h=10;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.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 iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h)); + try testing.expectEqual(3, x); + try testing.expectEqual(3, y); + try testing.expectEqual(1, w); + try testing.expectEqual(1, h); +} + +test "placement_source_rect null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_source_rect(null, null, &x, &y, &w, &h)); +} + test "image_get on null returns invalid_value" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 3f5f65f49..e7a7db68a 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -14,11 +14,14 @@ pub const kitty_graphics_image = kitty_graphics.image_get_handle; pub const kitty_graphics_image_get = kitty_graphics.image_get; pub const kitty_graphics_placement_iterator_new = kitty_graphics.placement_iterator_new; pub const kitty_graphics_placement_iterator_free = kitty_graphics.placement_iterator_free; +pub const kitty_graphics_placement_iterator_set = kitty_graphics.placement_iterator_set; pub const kitty_graphics_placement_next = kitty_graphics.placement_iterator_next; pub const kitty_graphics_placement_get = kitty_graphics.placement_get; pub const kitty_graphics_placement_rect = kitty_graphics.placement_rect; pub const kitty_graphics_placement_pixel_size = kitty_graphics.placement_pixel_size; pub const kitty_graphics_placement_grid_size = kitty_graphics.placement_grid_size; +pub const kitty_graphics_placement_viewport_pos = kitty_graphics.placement_viewport_pos; +pub const kitty_graphics_placement_source_rect = kitty_graphics.placement_source_rect; pub const types = @import("types.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig");