libghostty: expose row-local render selections

Render state already tracks the selected cell range for each viewport row,
but C renderers could only get the full terminal selection. That required
consumers to map global selection pins back into row-local spans themselves.

Add row selection data to the render-state row API. Querying the new row
data returns GHOSTTY_NO_VALUE for unselected rows and writes the inclusive
start and end columns for selected rows. The render example now demonstrates
setting a selection and reading the row-local range while iterating rows.
This commit is contained in:
Mitchell Hashimoto
2026-05-23 14:58:56 -07:00
parent ae03d3cae4
commit 24048ffd47
4 changed files with 141 additions and 2 deletions

View File

@@ -2,8 +2,8 @@
This contains an example of how to use the `ghostty-vt` render-state API
to create a render state, update it from terminal content, iterate rows
and cells, read styles and colors, inspect cursor state, and manage dirty
tracking.
and cells, read styles and colors, inspect cursor and row-local selection
state, and manage dirty tracking.
This uses a `build.zig` and `Zig` to build the C program so that we
can reuse a lot of our build logic and depend directly on our source

View File

@@ -46,6 +46,32 @@ int main(void) {
ghostty_terminal_vt_write(
terminal, (const uint8_t*)content, strlen(content));
// Select "underlined" on the second row. Render state exposes this
// later as a row-local selected cell range.
GhosttyGridRef selection_start = GHOSTTY_INIT_SIZED(GhosttyGridRef);
GhosttyPoint selection_start_pt = {
.tag = GHOSTTY_POINT_TAG_ACTIVE,
.value = { .coordinate = { .x = 0, .y = 1 } },
};
result = ghostty_terminal_grid_ref(
terminal, selection_start_pt, &selection_start);
assert(result == GHOSTTY_SUCCESS);
GhosttyGridRef selection_end = GHOSTTY_INIT_SIZED(GhosttyGridRef);
GhosttyPoint selection_end_pt = {
.tag = GHOSTTY_POINT_TAG_ACTIVE,
.value = { .coordinate = { .x = 9, .y = 1 } },
};
result = ghostty_terminal_grid_ref(terminal, selection_end_pt, &selection_end);
assert(result == GHOSTTY_SUCCESS);
GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection);
selection.start = selection_start;
selection.end = selection_end;
result = ghostty_terminal_set(
terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection);
assert(result == GHOSTTY_SUCCESS);
result = ghostty_render_state_update(render_state, terminal);
assert(result == GHOSTTY_SUCCESS);
//! [render-state-update]
@@ -154,6 +180,18 @@ int main(void) {
printf("Row %2d [%s]: ", row_index,
row_dirty ? "dirty" : "clean");
// Query the row-local selection range. Rows without a selection return
// GHOSTTY_NO_VALUE; selected rows return inclusive start/end columns.
GhosttyRenderStateRowSelection row_selection =
GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection);
result = ghostty_render_state_row_get(
row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION, &row_selection);
assert(result == GHOSTTY_SUCCESS || result == GHOSTTY_NO_VALUE);
if (result == GHOSTTY_SUCCESS) {
printf("selection=%u..%u ",
row_selection.start_x, row_selection.end_x);
}
// Get cells for this row (reuses the same cells handle).
result = ghostty_render_state_row_get(
row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, &cells);

View File

@@ -221,6 +221,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
* valid as long as the underlying render state is not updated.
* It is unsafe to use cell data after updating the render state. */
GHOSTTY_RENDER_STATE_ROW_DATA_CELLS = 3,
/** Row-local selected cell range (GhosttyRenderStateRowSelection). */
GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4,
GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateRowData;
@@ -235,6 +238,29 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateRowOption;
/**
* Row-local selection range.
*
* This struct uses the sized-struct ABI pattern. Initialize with
* GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection) before querying
* GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION.
*
* Querying GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION returns GHOSTTY_NO_VALUE
* if the current row does not intersect the current selection.
*
* @ingroup render
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateRowSelection). */
size_t size;
/** Start column of the row-local selection range, inclusive. */
uint16_t start_x;
/** End column of the row-local selection range, inclusive. */
uint16_t end_x;
} GhosttyRenderStateRowSelection;
/**
* Render-state color information.
*

View File

@@ -31,6 +31,7 @@ const RowIteratorWrapper = struct {
/// These are the raw pointers into the render state data.
raws: []const page.Row,
cells: []const std.MultiArrayList(renderpkg.RenderState.Cell),
selection: []const ?[2]size.CellCountInt,
dirty: []bool,
/// The color palette from the render state, needed to resolve
@@ -61,6 +62,13 @@ pub const RowCells = ?*RowCellsWrapper;
/// C: GhosttyRenderStateDirty
pub const Dirty = renderpkg.RenderState.Dirty;
/// C: GhosttyRenderStateRowSelection
pub const RowSelection = extern struct {
size: usize = @sizeOf(RowSelection),
start_x: u16 = 0,
end_x: u16 = 0,
};
/// C: GhosttyRenderStateCursorVisualStyle
pub const CursorVisualStyle = enum(c_int) {
bar = 0,
@@ -241,6 +249,7 @@ fn getTyped(
.y = null,
.raws = row_data.items(.raw),
.cells = row_data.items(.cells),
.selection = row_data.items(.selection),
.dirty = row_data.items(.dirty),
.palette = &state.state.colors.palette,
};
@@ -381,6 +390,7 @@ pub fn row_iterator_new(
.y = undefined,
.raws = undefined,
.cells = undefined,
.selection = undefined,
.dirty = undefined,
.palette = undefined,
};
@@ -564,6 +574,7 @@ pub const RowData = enum(c_int) {
dirty = 1,
raw = 2,
cells = 3,
selection = 4,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: RowData) type {
@@ -572,6 +583,7 @@ pub const RowData = enum(c_int) {
.dirty => bool,
.raw => row.CRow,
.cells => RowCells,
.selection => RowSelection,
};
}
};
@@ -654,6 +666,14 @@ fn rowGetTyped(
.palette = it.palette,
};
},
.selection => {
const out_size = out.size;
if (out_size < @sizeOf(RowSelection)) return .invalid_value;
const sel = it.selection[y] orelse return .no_value;
out.start_x = sel[0];
out.end_x = sel[1];
},
}
return .success;
@@ -845,6 +865,7 @@ test "render: row iterator new/free" {
try testing.expectEqual(@as(?size.CellCountInt, null), iterator_ptr.y);
try testing.expectEqual(row_data.items(.raw).len, iterator_ptr.raws.len);
try testing.expectEqual(row_data.items(.cells).len, iterator_ptr.cells.len);
try testing.expectEqual(row_data.items(.selection).len, iterator_ptr.selection.len);
try testing.expectEqual(row_data.items(.dirty).len, iterator_ptr.dirty.len);
}
@@ -1026,6 +1047,60 @@ test "render: row get/set dirty" {
try testing.expect(!dirty);
}
test "render: row get selection" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
.{
.cols = 10,
.rows = 3,
.max_scrollback = 10_000,
},
));
defer terminal_c.free(terminal);
const t = terminal.?.terminal;
const screen = t.screens.active;
try screen.select(.init(
screen.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?,
screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
false,
));
var state: RenderState = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&state,
));
defer free(state);
try testing.expectEqual(Result.success, update(state, terminal));
var it: RowIterator = null;
try testing.expectEqual(Result.success, row_iterator_new(
&lib.alloc.test_allocator,
&it,
));
defer row_iterator_free(it);
try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it)));
var sel: RowSelection = .{};
try testing.expect(row_iterator_next(it));
try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel)));
try testing.expect(row_iterator_next(it));
sel = .{};
try testing.expectEqual(Result.success, row_get(it, .selection, @ptrCast(&sel)));
try testing.expectEqual(@as(u16, 2), sel.start_x);
try testing.expectEqual(@as(u16, 4), sel.end_x);
try testing.expect(row_iterator_next(it));
sel = .{};
try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel)));
}
test "render: row iterator next" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(