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.
This commit is contained in:
Mitchell Hashimoto
2026-05-24 20:22:30 -07:00
parent cf9e85ecd7
commit edf2da0157
2 changed files with 102 additions and 0 deletions

View File

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

View File

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