From 66bfdf8e7a2662d9a10c702edd69bc14cc0886a6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 12:22:23 -0700 Subject: [PATCH 1/4] libghostty: add z-layer filtered placement iterator Add a placement_iterator_set function that configures iterator properties via an enum, following the same pattern as other set functions in the C API (e.g. render_state_set). The first settable option is a z-layer filter. The GhosttyKittyPlacementLayer enum classifies placements into three layers based on kitty protocol z-index conventions: below background (z < INT32_MIN/2), below text (INT32_MIN/2 <= z < 0), and above text (z >= 0). The default is ALL which preserves existing behavior. When a layer filter is set, placement_iterator_next automatically skips non-matching placements, so embedders no longer need to reimplement the z-index bucketing logic or iterate all placements three times per frame just to filter by layer. --- include/ghostty/vt/kitty_graphics.h | 59 ++++++++++ src/lib_vt.zig | 1 + src/terminal/c/kitty_graphics.zig | 162 +++++++++++++++++++++++++++- src/terminal/c/main.zig | 1 + 4 files changed, 221 insertions(+), 2 deletions(-) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index e31bb2c65..4f81598a4 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 * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 3799fbe66..1dbd321c4 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -239,6 +239,7 @@ 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" }); diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index f5811a024..b3095bf36 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; @@ -130,6 +131,34 @@ fn getTyped( 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 +262,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( @@ -537,6 +605,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; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 3f5f65f49..c0027d4e3 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -14,6 +14,7 @@ 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; From b43d35b4d3c147b637fed085fca4d4dad277fc80 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 12:27:17 -0700 Subject: [PATCH 2/4] libghostty: add viewport-relative placement positioning Add ghostty_kitty_graphics_placement_viewport_pos which converts a placement's internal pin to viewport-relative grid coordinates. The returned row can be negative when the placement's origin has scrolled above the viewport, allowing embedders to compute the correct destination rectangle for partially visible images. Returns GHOSTTY_NO_VALUE only when the placement is completely outside the viewport (bottom edge above the viewport or top edge at or below the last row), so embedders do not need to perform their own visibility checks. Partially visible placements always return GHOSTTY_SUCCESS with their true signed coordinates. --- include/ghostty/vt/kitty_graphics.h | 42 +++++ src/lib_vt.zig | 1 + src/terminal/c/kitty_graphics.zig | 261 ++++++++++++++++++++++++++++ src/terminal/c/main.zig | 1 + 4 files changed, 305 insertions(+) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index 4f81598a4..f0cc0f6aa 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -474,6 +474,48 @@ 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); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1dbd321c4..4b60ff8fa 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -245,6 +245,7 @@ comptime { @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.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 b3095bf36..b18affb55 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -421,6 +421,52 @@ 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; +} + test "placement_iterator new/free" { var iter: PlacementIterator = null; try testing.expectEqual(Result.success, placement_iterator_new( @@ -936,6 +982,221 @@ 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. + 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;\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. + // 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;\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. + // 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;\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 "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 c0027d4e3..85a223c89 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -20,6 +20,7 @@ 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 types = @import("types.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); From d712beff5b616f1f886937c6de8e8105b9f3956e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 12:36:34 -0700 Subject: [PATCH 3/4] libghostty: add resolved source rect for placements Add ghostty_kitty_graphics_placement_source_rect which returns the fully resolved and clamped source rectangle for a placement. This applies kitty protocol semantics (width/height of 0 means full image dimension) and clamps the result to the actual image bounds, eliminating ~20 lines of protocol-aware logic from each embedder. --- include/ghostty/vt/kitty_graphics.h | 27 +++++ src/lib_vt.zig | 1 + src/terminal/c/kitty_graphics.zig | 166 ++++++++++++++++++++++++++++ src/terminal/c/main.zig | 1 + 4 files changed, 195 insertions(+) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index f0cc0f6aa..446834d18 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -516,6 +516,33 @@ GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_viewport_pos( 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 4b60ff8fa..ff11177da 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -246,6 +246,7 @@ comptime { @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 b18affb55..0045f3368 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -467,6 +467,35 @@ pub fn placement_viewport_pos( 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( @@ -1197,6 +1226,143 @@ test "placement_viewport_pos null args return invalid_value" { 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: 86 chars. + const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ++ + "\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. + const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ++ + "\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 85a223c89..e7a7db68a 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -21,6 +21,7 @@ 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"); From 65e3265e3cd4df063a83fcabfa7fd2a4b61627b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 12:49:12 -0700 Subject: [PATCH 4/4] libghostty: fix kitty graphics test failures Fix three categories of test bugs in the kitty graphics C API tests: The placement iterator reset in getTyped was clobbering the layer_filter field when reinitializing the iterator struct, causing the layer filter test to see unfiltered placements. Preserve layer_filter across resets. The viewport position tests were not accounting for the default cursor_movement=after behavior of the kitty display command, which calls index() for each row of the placement before the test scroll sequence. Add C=1 to suppress cursor movement so the scroll math in the tests is correct. The source_rect tests used an 88-character all-A base64 payload which decodes to 66 bytes, but a 4x4 RGBA image requires exactly 64 bytes. Fix the payload to use proper base64 padding (AA==). --- AGENTS.md | 2 ++ src/terminal/c/kitty_graphics.zig | 18 +++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) 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/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 0045f3368..a086f8e9f 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -125,6 +125,7 @@ fn getTyped( it.* = .{ .alloc = it.alloc, .inner = storage.placements.iterator(), + .layer_filter = it.layer_filter, }; }, } @@ -1108,8 +1109,9 @@ test "placement_viewport_pos top off-screen" { 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;\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); @@ -1150,10 +1152,11 @@ test "placement_viewport_pos bottom off-screen" { 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;\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, cursor.ptr, cursor.len); terminal_c.vt_write(t, display.ptr, display.len); @@ -1189,10 +1192,11 @@ test "placement_viewport_pos top and bottom off-screen" { 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;\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); @@ -1278,9 +1282,9 @@ test "placement_source_rect with explicit source rect" { 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: 86 chars. + // 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;" ++ - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ++ + "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\\"; @@ -1321,9 +1325,9 @@ test "placement_source_rect clamps to image bounds" { defer terminal_c.free(t); try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); - // Transmit a 4x4 RGBA image. + // 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;" ++ - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ++ + "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.