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.
This commit is contained in:
Mitchell Hashimoto
2026-04-06 12:27:17 -07:00
parent 66bfdf8e7a
commit b43d35b4d3
4 changed files with 305 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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