diff --git a/example/c-vt-render/README.md b/example/c-vt-render/README.md index 3725ed46f..b56cd8384 100644 --- a/example/c-vt-render/README.md +++ b/example/c-vt-render/README.md @@ -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 diff --git a/example/c-vt-render/src/main.c b/example/c-vt-render/src/main.c index 0714d4160..feb3628d4 100644 --- a/example/c-vt-render/src/main.c +++ b/example/c-vt-render/src/main.c @@ -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); diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index d1a3687d9..f1f201c44 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -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. * diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index af82ddfa1..f8b48353f 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -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(