From edf2da015705db22fffdcab62a0871c898fa064b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 20:22:30 -0700 Subject: [PATCH] libghostty: expose per-cell selection state Render-state rows already expose their selected range, but cell-oriented C API consumers had to fetch that row range separately and duplicate the containment check while rendering. Add a SELECTED row-cells data kind that carries the row selection into the row-cells wrapper and returns whether the current cell column is in that inclusive range. The field remains separate from cell colors and style so selection stays an explicit render overlay policy. For performance reasons, the span-based row getter is recommended still but this is a convenient thing to do for cell-oriented folks. --- include/ghostty/vt/render.h | 10 ++++ src/terminal/c/render.zig | 92 +++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index f1f201c44..dff101dcc 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -597,6 +597,16 @@ typedef enum GHOSTTY_ENUM_TYPED { * color, in which case the caller should use whatever default foreground * color it wants (e.g. the terminal foreground). */ GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6, + + /** Whether the cell is contained within the current selection (bool). + * This returns true when the cell's column is within the current row's + * row-local selection range, and false otherwise. Rendering policy for + * selected cells (colors, inversion, etc.) is left to the caller. + * + * Renderers that can draw cells in spans may be more efficient querying + * GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION once per row and applying that + * range directly, avoiding one C API call per cell for selection state. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED = 7, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowCellsData; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index f8b48353f..a33307151 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -45,6 +45,7 @@ const RowCellsWrapper = struct { raws: []const page.Cell, graphemes: []const []const u21, styles: []const Style, + selection: ?[2]size.CellCountInt, /// The color palette, needed to resolve palette-indexed background colors. palette: *const colorpkg.Palette, @@ -427,6 +428,7 @@ pub fn row_cells_new( .raws = undefined, .graphemes = undefined, .styles = undefined, + .selection = undefined, .palette = undefined, }; result.* = ptr; @@ -463,6 +465,7 @@ pub const RowCellsData = enum(c_int) { graphemes_buf = 4, bg_color = 5, fg_color = 6, + selected = 7, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: RowCellsData) type { @@ -473,6 +476,7 @@ pub const RowCellsData = enum(c_int) { .graphemes_len => u32, .graphemes_buf => u32, .bg_color, .fg_color => colorpkg.RGB.C, + .selected => bool, }; } }; @@ -563,6 +567,10 @@ fn rowCellsGetTyped( const fg = s.fg(.{ .default = .{}, .palette = cells.palette }); out.* = fg.cval(); }, + .selected => out.* = if (cells.selection) |sel| + x >= sel[0] and x <= sel[1] + else + false, } return .success; @@ -663,6 +671,7 @@ fn rowGetTyped( .raws = cell_data.items(.raw), .graphemes = cell_data.items(.grapheme), .styles = cell_data.items(.style), + .selection = it.selection[y], .palette = it.palette, }; }, @@ -1101,6 +1110,89 @@ test "render: row get selection" { try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel))); } +test "render: row cells get selected" { + 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); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + + var selected: bool = true; + try testing.expectEqual(Result.success, row_cells_select(cells, 0)); + try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected))); + try testing.expect(!selected); + + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + + try testing.expectEqual(Result.success, row_cells_select(cells, 1)); + try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected))); + try testing.expect(!selected); + + try testing.expectEqual(Result.success, row_cells_select(cells, 2)); + try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected))); + try testing.expect(selected); + + try testing.expectEqual(Result.success, row_cells_select(cells, 4)); + try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected))); + try testing.expect(selected); + + try testing.expectEqual(Result.success, row_cells_select(cells, 5)); + try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected))); + try testing.expect(!selected); + + try testing.expectEqual(Result.success, row_cells_select(cells, 3)); + selected = false; + var written: usize = 0; + const keys = [_]RowCellsData{.selected}; + var values = [_]?*anyopaque{@ptrCast(&selected)}; + try testing.expectEqual(Result.success, row_cells_get_multi(cells, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expect(selected); +} + test "render: row iterator next" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new(