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.
This commit is contained in:
Mitchell Hashimoto
2026-04-06 12:36:34 -07:00
parent b43d35b4d3
commit d712beff5b
4 changed files with 195 additions and 0 deletions

View File

@@ -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

View File

@@ -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" });

View File

@@ -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;

View File

@@ -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");