From ae03d3cae4d4af244a43d91a0d8040739899e4a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 14:54:34 -0700 Subject: [PATCH 01/14] libghostty: expose get/set active selection state Add terminal set/get support for the active screen selection through the existing option and data APIs. Setting a selection copies the C snapshot into terminal-owned tracked state, while passing NULL clears the current selection. Getting the selection now returns an untracked GhosttySelection snapshot or GHOSTTY_NO_VALUE when there is no selection. The C header documents the different lifetimes for set and get so embedders know when input and returned grid references remain valid. --- include/ghostty/vt/terminal.h | 33 ++++++++++++++++++ src/terminal/c/selection.zig | 8 +++++ src/terminal/c/terminal.zig | 64 +++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 1751aa126..756698449 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #ifdef __cplusplus @@ -592,6 +593,21 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: size_t* */ GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY = 20, + + /** + * Set the active screen selection. + * + * The value must point to a GhosttySelection whose grid references are + * valid for this terminal's active screen at the time of the call. The + * terminal copies the selection immediately and converts it to + * terminal-owned tracked state, so the GhosttySelection struct and its + * untracked grid references do not need to outlive this call. + * + * Passing NULL clears the active screen selection. + * + * Input type: GhosttySelection* + */ + GHOSTTY_TERMINAL_OPT_SELECTION = 21, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; @@ -868,6 +884,23 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: GhosttyKittyGraphics * */ GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30, + + /** + * The active screen's current selection. + * + * On success, writes an untracked snapshot of the terminal-owned selection + * to the caller-provided GhosttySelection. The GhosttySelection struct is + * caller-owned and may be kept, but the grid references inside it are + * untracked borrowed references into the active screen. They are only valid + * until the next mutating terminal call, such as ghostty_terminal_set(), + * ghostty_terminal_vt_write(), ghostty_terminal_resize(), or + * ghostty_terminal_reset(). + * + * Returns GHOSTTY_NO_VALUE when there is no active selection. + * + * Output type: GhosttySelection * + */ + GHOSTTY_TERMINAL_DATA_SELECTION = 31, GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalData; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 74e96598f..c4c6284e6 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -13,4 +13,12 @@ pub const CSelection = extern struct { const end_pin = self.end.toPin() orelse return null; return Selection.init(start_pin, end_pin, self.rectangle); } + + pub fn fromZig(sel: Selection) CSelection { + return .{ + .start = .fromPin(sel.start()), + .end = .fromPin(sel.end()), + .rectangle = sel.rectangle, + }; + } }; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 662a2ec03..2e88ea524 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -20,6 +20,7 @@ const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const grid_ref_c = @import("grid_ref.zig"); const grid_ref_tracked_c = @import("grid_ref_tracked.zig"); +const selection_c = @import("selection.zig"); const style_c = @import("style.zig"); const color = @import("../color.zig"); const Result = @import("result.zig").Result; @@ -314,6 +315,7 @@ pub const Option = enum(c_int) { kitty_image_medium_shared_mem = 18, apc_max_bytes = 19, apc_max_bytes_kitty = 20, + selection = 21, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -336,6 +338,7 @@ pub const Option = enum(c_int) { .kitty_image_medium_shared_mem, => ?*const bool, .apc_max_bytes, .apc_max_bytes_kitty => ?*const usize, + .selection => ?*const selection_c.CSelection, }; } }; @@ -443,6 +446,14 @@ fn setTyped( wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty); } }, + .selection => { + if (value) |ptr| { + const sel = ptr.toZig() orelse return .invalid_value; + wrapper.terminal.screens.active.select(sel) catch return .out_of_memory; + } else { + wrapper.terminal.screens.active.clearSelection(); + } + }, } return .success; } @@ -576,6 +587,7 @@ pub const TerminalData = enum(c_int) { kitty_image_medium_temp_file = 28, kitty_image_medium_shared_mem = 29, kitty_graphics = 30, + selection = 31, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { @@ -604,6 +616,7 @@ pub const TerminalData = enum(c_int) { .kitty_image_medium_shared_mem, => bool, .kitty_graphics => KittyGraphics, + .selection => selection_c.CSelection, }; } }; @@ -713,6 +726,9 @@ fn getTyped( if (comptime !build_options.kitty_graphics) return .no_value; out.* = &t.screens.active.kitty_images; }, + .selection => out.* = selection_c.CSelection.fromZig( + t.screens.active.selection orelse return .no_value, + ), } return .success; @@ -1325,6 +1341,54 @@ test "get invalid" { try testing.expectEqual(Result.invalid_value, get(t, .invalid, null)); } +test "set and get selection" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 4, .y = 0 } }, + }, &end_ref)); + + var out: selection_c.CSelection = undefined; + try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out))); + + const sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + .rectangle = true, + }; + try testing.expectEqual(Result.success, set(t, .selection, @ptrCast(&sel))); + try testing.expect(t.?.terminal.screens.active.selection.?.tracked()); + + try testing.expectEqual(Result.success, get(t, .selection, @ptrCast(&out))); + try testing.expect(out.start.toPin().?.eql(start_ref.toPin().?)); + try testing.expect(out.end.toPin().?.eql(end_ref.toPin().?)); + try testing.expect(out.rectangle); + + try testing.expectEqual(Result.success, set(t, .selection, null)); + try testing.expect(t.?.terminal.screens.active.selection == null); + try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out))); +} + test "grid_ref" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 24048ffd471c5e88006bafc6ab3e5eb3c710a15d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 14:58:56 -0700 Subject: [PATCH 02/14] 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. --- example/c-vt-render/README.md | 4 +- example/c-vt-render/src/main.c | 38 +++++++++++++++++ include/ghostty/vt/render.h | 26 ++++++++++++ src/terminal/c/render.zig | 75 ++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) 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( From 545a5aef663ef551bd8b2b2b794f47d9a74d7586 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:04:51 -0700 Subject: [PATCH 03/14] libghostty: document selection snapshot lifetime Clarify that GhosttySelection is a snapshot type whose endpoints are untracked GhosttyGridRef values. The previous documentation described the range shape but did not repeat the grid reference lifetime caveat, which made it easy to keep selections across terminal mutations incorrectly. --- include/ghostty/vt/selection.h | 45 +++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 9f878fadc..3ba2f00db 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -17,14 +17,34 @@ extern "C" { /** @defgroup selection Selection * - * A selection range defined by two grid references that identifies a - * contiguous or rectangular region of terminal content. + * A snapshot selection range defined by two grid references that identifies + * a contiguous or rectangular region of terminal content. + * + * The start and end values are GhosttyGridRef values. They are therefore + * untracked grid references and inherit the same lifetime rules: they are + * only safe to use until the next mutating operation on the terminal that + * produced them, including freeing the terminal. To keep a selection valid + * across terminal mutations, callers must maintain tracked grid references + * for the endpoints and reconstruct a GhosttySelection from fresh snapshots + * when needed. * * @{ */ /** - * A selection range defined by two grid references. + * A snapshot selection range defined by two grid references. + * + * Both endpoints are inclusive. The endpoints preserve selection direction + * and may be reversed; callers must not assume that start is the top-left + * endpoint or that end is the bottom-right endpoint. + * + * When rectangle is false, the endpoints describe a linear selection. When + * rectangle is true, the same endpoints are interpreted as opposite corners + * of a rectangular/block selection. + * + * The start and end values are untracked GhosttyGridRef snapshots and are + * only valid until the next mutating operation on the terminal that produced + * them unless the selection is reconstructed from tracked references. * * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. * @@ -34,13 +54,26 @@ typedef struct { /** Size of this struct in bytes. Must be set to sizeof(GhosttySelection). */ size_t size; - /** Start of the selection range (inclusive). */ + /** + * Start of the selection range (inclusive). + * + * This may be after end in terminal order. It is an untracked + * GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules. + */ GhosttyGridRef start; - /** End of the selection range (inclusive). */ + /** + * End of the selection range (inclusive). + * + * This may be before start in terminal order. It is an untracked + * GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules. + */ GhosttyGridRef end; - /** Whether the selection is rectangular (block) rather than linear. */ + /** + * Whether the endpoints are interpreted as a rectangular/block selection + * rather than a linear selection. + */ bool rectangle; } GhosttySelection; From 15d89636814ac0d8d0beb783da0dee6ba63f8f7c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:08:38 -0700 Subject: [PATCH 04/14] libghostty: add selection adjustment api --- include/ghostty/vt/selection.h | 55 +++++++++++++++++++++++++++ include/ghostty/vt/terminal.h | 29 ++++++++++++++ src/lib_vt.zig | 1 + src/terminal/Selection.zig | 25 ++++++------ src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 69 ++++++++++++++++++++++++++++++++++ 6 files changed, 168 insertions(+), 12 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 3ba2f00db..cc20a0691 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -77,6 +77,61 @@ typedef struct { bool rectangle; } GhosttySelection; +/** + * Operation used to adjust a selection endpoint. + * + * Adjustment mutates the selection's logical end endpoint, not whichever + * endpoint is visually bottom/right. This preserves keyboard and drag + * behavior for both forward and reversed selections. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Move left to the previous non-empty cell, wrapping upward. */ + GHOSTTY_SELECTION_ADJUST_LEFT = 0, + + /** Move right to the next non-empty cell, wrapping downward. */ + GHOSTTY_SELECTION_ADJUST_RIGHT = 1, + + /** + * Move up one row at the current column, or to the beginning of the + * line if already at the top. + */ + GHOSTTY_SELECTION_ADJUST_UP = 2, + + /** + * Move down to the next non-blank row at the current column, or to the + * end of the line if none exists. + */ + GHOSTTY_SELECTION_ADJUST_DOWN = 3, + + /** Move to the top-left cell of the screen. */ + GHOSTTY_SELECTION_ADJUST_HOME = 4, + + /** Move to the right edge of the last non-blank row on the screen. */ + GHOSTTY_SELECTION_ADJUST_END = 5, + + /** + * Move up by one terminal page height, or to home if that would move + * past the top. + */ + GHOSTTY_SELECTION_ADJUST_PAGE_UP = 6, + + /** + * Move down by one terminal page height, or to end if that would move + * past the bottom. + */ + GHOSTTY_SELECTION_ADJUST_PAGE_DOWN = 7, + + /** Move to the left edge of the current line. */ + GHOSTTY_SELECTION_ADJUST_BEGINNING_OF_LINE = 8, + + /** Move to the right edge of the current line. */ + GHOSTTY_SELECTION_ADJUST_END_OF_LINE = 9, + + GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionAdjust; + /** @} */ #ifdef __cplusplus diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 756698449..55886e395 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1123,6 +1123,35 @@ GHOSTTY_API GhosttyResult ghostty_terminal_get_multi(GhosttyTerminal terminal, void** values, size_t* out_written); +/** + * Adjust a selection snapshot using terminal selection semantics. + * + * This mutates the caller-provided GhosttySelection in place. The logical end + * endpoint is always moved, regardless of whether the selection is forward or + * reversed visually. The input selection remains a snapshot: after adjustment, + * call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it + * as the terminal-owned selection if desired. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to adjust in place + * @param adjustment The adjustment operation to apply + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, or adjustment are invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( + GhosttyTerminal terminal, + GhosttySelection* selection, + GhosttySelectionAdjust adjustment); + /** * Resolve a point in the terminal grid to a grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 12aa66bfe..6276f707c 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -239,6 +239,7 @@ comptime { @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); + @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 8cb52816c..4d7d4a2fa 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -4,6 +4,7 @@ const Selection = @This(); const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; +const lib = @import("lib.zig"); const page = @import("page.zig"); const point = @import("point.zig"); const PageList = @import("PageList.zig"); @@ -389,18 +390,18 @@ pub fn containedRowCached( } /// Possible adjustments to the selection. -pub const Adjustment = enum { - left, - right, - up, - down, - home, - end, - page_up, - page_down, - beginning_of_line, - end_of_line, -}; +pub const Adjustment = lib.Enum(lib.target, &.{ + "left", + "right", + "up", + "down", + "home", + "end", + "page_up", + "page_down", + "beginning_of_line", + "end_of_line", +}); /// Adjust the selection by some given adjustment. An adjustment allows /// a selection to be expanded slightly left, right, up, down, etc. diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index ab6ab719b..35fb8b197 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -170,6 +170,7 @@ pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; +pub const terminal_selection_adjust = terminal.selection_adjust; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 2e88ea524..fef6fbad9 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -7,6 +7,7 @@ const ZigTerminal = @import("../Terminal.zig"); const Stream = @import("../stream_terminal.zig").Stream; const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); +const Selection = @import("../Selection.zig"); const apc = @import("../apc.zig"); const kitty = @import("../kitty/key.zig"); const kitty_gfx_c = @import("kitty_graphics.zig"); @@ -664,6 +665,26 @@ pub fn get_multi( return .success; } +pub fn selection_adjust( + terminal_: Terminal, + selection: ?*selection_c.CSelection, + adjustment: Selection.Adjustment, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Selection.Adjustment, @intFromEnum(adjustment)) catch { + log.warn("terminal_selection_adjust invalid adjustment value={d}", .{@intFromEnum(adjustment)}); + return .invalid_value; + }; + } + + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const sel_ptr = selection orelse return .invalid_value; + var sel = sel_ptr.toZig() orelse return .invalid_value; + sel.adjust(t.screens.active, adjustment); + sel_ptr.* = .fromZig(sel); + return .success; +} + fn getTyped( terminal_: Terminal, comptime data: TerminalData, @@ -1389,6 +1410,54 @@ test "set and get selection" { try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out))); } +test "selection_adjust mutates snapshot end" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &end_ref)); + + var sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + try testing.expectEqual(Result.success, selection_adjust(t, &sel, .right)); + try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 2), sel.end.toPin().?.x); + + try testing.expectEqual(Result.success, selection_adjust(t, &sel, .left)); + try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); + + sel = .{ + .start = end_ref, + .end = start_ref, + }; + try testing.expectEqual(Result.success, selection_adjust(t, &sel, .right)); + try testing.expectEqual(@as(u16, 1), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); +} + test "grid_ref" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 4a77e8196720088cbdce701c88412d3ba16089b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:15:03 -0700 Subject: [PATCH 05/14] libghostty: add selection ordering APIs Expose selection endpoint ordering through the libghostty-vt C API so embedders can safely normalize selections whose start and end refs may be reversed. The new APIs report the current order and return a fresh untracked selection with forward or reverse bounds. Selection.Order now uses lib.Enum, matching the existing adjustment enum pattern and keeping the C ABI enum generated from the same Zig source of truth. The new functions are wired through the C API re-export and lib-vt export paths, with coverage for mirrored rectangular selection ordering. --- include/ghostty/vt/selection.h | 25 +++++++ include/ghostty/vt/terminal.h | 55 ++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/Selection.zig | 7 +- src/terminal/c/main.zig | 2 + src/terminal/c/terminal.zig | 115 +++++++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index cc20a0691..de00899aa 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -77,6 +77,31 @@ typedef struct { bool rectangle; } GhosttySelection; +/** + * Ordering of a selection's endpoints in terminal coordinates. + * + * Mirrored orders are only produced by rectangular selections whose start + * and end endpoints are on opposite diagonal corners that are not simple + * top-left-to-bottom-right or bottom-right-to-top-left orderings. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Start is before end in top-left to bottom-right order. */ + GHOSTTY_SELECTION_ORDER_FORWARD = 0, + + /** End is before start in top-left to bottom-right order. */ + GHOSTTY_SELECTION_ORDER_REVERSE = 1, + + /** Rectangular selection from top-right to bottom-left. */ + GHOSTTY_SELECTION_ORDER_MIRRORED_FORWARD = 2, + + /** Rectangular selection from bottom-left to top-right. */ + GHOSTTY_SELECTION_ORDER_MIRRORED_REVERSE = 3, + + GHOSTTY_SELECTION_ORDER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionOrder; + /** * Operation used to adjust a selection endpoint. * diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 55886e395..f9c951b47 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1152,6 +1152,61 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( GhosttySelection* selection, GhosttySelectionAdjust adjustment); +/** + * Get the current endpoint ordering of a selection snapshot. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param[out] out_order On success, receives the selection order + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, or output pointer are invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder* out_order); + +/** + * Return a selection snapshot with endpoints ordered as requested. + * + * Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds, + * and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds. + * Mirrored desired orders are accepted but normalized the same as forward. + * The output selection is a fresh untracked snapshot and is not installed as + * the terminal's current selection. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to order + * @param desired Desired endpoint order + * @param[out] out_selection On success, receives the ordered selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, desired order, or output pointer + * are invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder desired, + GhosttySelection* out_selection); + /** * Resolve a point in the terminal grid to a grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 6276f707c..1feb51932 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -240,6 +240,8 @@ comptime { @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); + @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); + @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 4d7d4a2fa..5258210cf 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -196,7 +196,12 @@ pub fn bottomRight(self: Selection, s: *const Screen) Pin { /// operations only flip the x or y axis, not both. Depending on the y axis /// direction, this is either mirrored_forward or mirrored_reverse. /// -pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; +pub const Order = lib.Enum(lib.target, &.{ + "forward", + "reverse", + "mirrored_forward", + "mirrored_reverse", +}); pub fn order(self: Selection, s: *const Screen) Order { const start_pt = s.pages.pointFromPin(.screen, self.start()).?.screen; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 35fb8b197..e495cda1a 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -171,6 +171,8 @@ pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; pub const terminal_selection_adjust = terminal.selection_adjust; +pub const terminal_selection_order = terminal.selection_order; +pub const terminal_selection_ordered = terminal.selection_ordered; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index fef6fbad9..98208c102 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -685,6 +685,50 @@ pub fn selection_adjust( return .success; } +pub fn selection_order( + terminal_: Terminal, + selection: ?*const selection_c.CSelection, + out_order: ?*Selection.Order, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_order orelse return .invalid_value; + if (!selectionValid(t, sel)) return .invalid_value; + + out.* = sel.order(t.screens.active); + return .success; +} + +pub fn selection_ordered( + terminal_: Terminal, + selection: ?*const selection_c.CSelection, + desired: Selection.Order, + out_selection: ?*selection_c.CSelection, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Selection.Order, @intFromEnum(desired)) catch { + log.warn("terminal_selection_ordered invalid desired value={d}", .{@intFromEnum(desired)}); + return .invalid_value; + }; + } + + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_selection orelse return .invalid_value; + if (!selectionValid(t, sel)) return .invalid_value; + + out.* = .fromZig(sel.ordered(t.screens.active, desired)); + return .success; +} + +fn selectionValid(t: *ZigTerminal, sel: Selection) bool { + const screen = t.screens.active; + return screen.pages.pointFromPin(.screen, sel.start()) != null and + screen.pages.pointFromPin(.screen, sel.end()) != null; +} + fn getTyped( terminal_: Terminal, comptime data: TerminalData, @@ -1458,6 +1502,77 @@ test "selection_adjust mutates snapshot end" { try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); } +test "selection_order and selection_ordered" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello\r\nWorld", 12); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 1 } }, + }, &end_ref)); + + const sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + .rectangle = true, + }; + + var order: Selection.Order = undefined; + try testing.expectEqual(Result.success, selection_order(t, &sel, &order)); + try testing.expectEqual(Selection.Order.mirrored_forward, order); + + var out: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, selection_ordered(t, &sel, .forward, &out)); + try testing.expectEqual(@as(u16, 1), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y); + try testing.expectEqual(@as(u16, 3), out.end.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y); + try testing.expect(out.rectangle); + + try testing.expectEqual(Result.success, selection_ordered(t, &sel, .reverse, &out)); + try testing.expectEqual(@as(u16, 3), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y); + try testing.expectEqual(@as(u16, 1), out.end.toPin().?.x); + try testing.expectEqual(@as(u16, 0), out.end.toPin().?.y); + try testing.expect(out.rectangle); +} + +test "selection_order invalid values" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var order: Selection.Order = undefined; + try testing.expectEqual(Result.invalid_value, selection_order(null, null, &order)); + try testing.expectEqual(Result.invalid_value, selection_order(t, null, &order)); +} + test "grid_ref" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 671c12fad9f85c8b384773c3ba936b07b4af45bf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:17:18 -0700 Subject: [PATCH 06/14] libghostty: add selection contains API Expose a C API for checking whether a GhosttyPoint is inside a GhosttySelection. The new terminal helper validates the selection snapshot against the active screen, resolves the point to a grid pin, and delegates to the internal Selection.contains implementation so C consumers get the same linear and rectangular selection semantics as Ghostty. Wire the symbol through the C API exports and public headers, and add a focused test covering linear containment and rectangular selection behavior. --- include/ghostty/vt.h | 1 + include/ghostty/vt/terminal.h | 28 +++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 76 +++++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 75bbb3b5b..7a6a9758a 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -136,6 +136,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index f9c951b47..b525af54e 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1207,6 +1207,34 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( GhosttySelectionOrder desired, GhosttySelection* out_selection); +/** + * Test whether a terminal point is inside a selection snapshot. + * + * This uses the same selection semantics as the terminal, including + * rectangular/block selections and linear selections spanning multiple rows. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param point Point to test for containment + * @param[out] out_contains On success, receives whether point is inside selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, point, or output pointer are invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttyPoint point, + bool* out_contains); + /** * Resolve a point in the terminal grid to a grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1feb51932..6e3ff18cd 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -242,6 +242,7 @@ comptime { @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); + @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index e495cda1a..0b75fe81b 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -173,6 +173,7 @@ pub const terminal_get_multi = terminal.get_multi; pub const terminal_selection_adjust = terminal.selection_adjust; pub const terminal_selection_order = terminal.selection_order; pub const terminal_selection_ordered = terminal.selection_ordered; +pub const terminal_selection_contains = terminal.selection_contains; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 98208c102..6dc8f9e90 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -723,6 +723,24 @@ pub fn selection_ordered( return .success; } +pub fn selection_contains( + terminal_: Terminal, + selection: ?*const selection_c.CSelection, + pt: point.Point.C, + out_contains: ?*bool, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_contains orelse return .invalid_value; + if (!selectionValid(t, sel)) return .invalid_value; + + const screen = t.screens.active; + const pin = screen.pages.pin(.fromC(pt)) orelse return .invalid_value; + out.* = sel.contains(screen, pin); + return .success; +} + fn selectionValid(t: *ZigTerminal, sel: Selection) bool { const screen = t.screens.active; return screen.pages.pointFromPin(.screen, sel.start()) != null and @@ -1555,6 +1573,64 @@ test "selection_order and selection_ordered" { try testing.expect(out.rectangle); } +test "selection_contains" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello\r\nWorld", 12); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 1 } }, + }, &end_ref)); + + const linear: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + + var contains: bool = undefined; + try testing.expectEqual(Result.success, selection_contains(t, &linear, .{ + .tag = .active, + .value = .{ .active = .{ .x = 4, .y = 0 } }, + }, &contains)); + try testing.expect(contains); + + try testing.expectEqual(Result.success, selection_contains(t, &linear, .{ + .tag = .active, + .value = .{ .active = .{ .x = 2, .y = 0 } }, + }, &contains)); + try testing.expect(!contains); + + const rectangle: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + .rectangle = true, + }; + + try testing.expectEqual(Result.success, selection_contains(t, &rectangle, .{ + .tag = .active, + .value = .{ .active = .{ .x = 2, .y = 0 } }, + }, &contains)); + try testing.expect(contains); +} + test "selection_order invalid values" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 2512fad9408bf0fee76dda2eae7a401e28d6b18f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:20:12 -0700 Subject: [PATCH 07/14] libghostty: move selection functions to selection doxygen group --- include/ghostty/vt/selection.h | 113 +++++++++++++++++++++++++++++++ include/ghostty/vt/terminal.h | 112 ------------------------------- src/terminal/c/main.zig | 8 +-- src/terminal/c/selection.zig | 93 ++++++++++++++++++++++++++ src/terminal/c/terminal.zig | 117 ++++++--------------------------- 5 files changed, 229 insertions(+), 214 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index de00899aa..e397e9a5d 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -10,6 +10,7 @@ #include #include #include +#include #ifdef __cplusplus extern "C" { @@ -157,6 +158,118 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionAdjust; +/** + * Adjust a selection snapshot using terminal selection semantics. + * + * This mutates the caller-provided GhosttySelection in place. The logical end + * endpoint is always moved, regardless of whether the selection is forward or + * reversed visually. The input selection remains a snapshot: after adjustment, + * call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it + * as the terminal-owned selection if desired. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to adjust in place + * @param adjustment The adjustment operation to apply + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, or adjustment are invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( + GhosttyTerminal terminal, + GhosttySelection* selection, + GhosttySelectionAdjust adjustment); + +/** + * Get the current endpoint ordering of a selection snapshot. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param[out] out_order On success, receives the selection order + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, or output pointer are invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder* out_order); + +/** + * Return a selection snapshot with endpoints ordered as requested. + * + * Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds, + * and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds. + * Mirrored desired orders are accepted but normalized the same as forward. + * The output selection is a fresh untracked snapshot and is not installed as + * the terminal's current selection. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to order + * @param desired Desired endpoint order + * @param[out] out_selection On success, receives the ordered selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, desired order, or output pointer + * are invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder desired, + GhosttySelection* out_selection); + +/** + * Test whether a terminal point is inside a selection snapshot. + * + * This uses the same selection semantics as the terminal, including + * rectangular/block selections and linear selections spanning multiple rows. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param point Point to test for containment + * @param[out] out_contains On success, receives whether point is inside selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, point, or output pointer are invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttyPoint point, + bool* out_contains); + /** @} */ #ifdef __cplusplus diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b525af54e..756698449 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1123,118 +1123,6 @@ GHOSTTY_API GhosttyResult ghostty_terminal_get_multi(GhosttyTerminal terminal, void** values, size_t* out_written); -/** - * Adjust a selection snapshot using terminal selection semantics. - * - * This mutates the caller-provided GhosttySelection in place. The logical end - * endpoint is always moved, regardless of whether the selection is forward or - * reversed visually. The input selection remains a snapshot: after adjustment, - * call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it - * as the terminal-owned selection if desired. - * - * The selection's start and end grid refs must both be valid untracked - * snapshots for the given terminal's currently active screen. In practice, - * they must come from that terminal and screen, and no mutating terminal call - * may have occurred since the refs were produced or reconstructed from - * tracked refs. Passing refs from another terminal, another screen, or stale - * refs violates this precondition. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param selection Selection snapshot to adjust in place - * @param adjustment The adjustment operation to apply - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, or adjustment are invalid - * - * @ingroup terminal - */ -GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( - GhosttyTerminal terminal, - GhosttySelection* selection, - GhosttySelectionAdjust adjustment); - -/** - * Get the current endpoint ordering of a selection snapshot. - * - * The selection's start and end grid refs must both be valid untracked - * snapshots for the given terminal's currently active screen. In practice, - * they must come from that terminal and screen, and no mutating terminal call - * may have occurred since the refs were produced or reconstructed from - * tracked refs. Passing refs from another terminal, another screen, or stale - * refs violates this precondition. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param selection Selection snapshot to inspect - * @param[out] out_order On success, receives the selection order - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, or output pointer are invalid - * - * @ingroup terminal - */ -GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( - GhosttyTerminal terminal, - const GhosttySelection* selection, - GhosttySelectionOrder* out_order); - -/** - * Return a selection snapshot with endpoints ordered as requested. - * - * Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds, - * and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds. - * Mirrored desired orders are accepted but normalized the same as forward. - * The output selection is a fresh untracked snapshot and is not installed as - * the terminal's current selection. - * - * The selection's start and end grid refs must both be valid untracked - * snapshots for the given terminal's currently active screen. In practice, - * they must come from that terminal and screen, and no mutating terminal call - * may have occurred since the refs were produced or reconstructed from - * tracked refs. Passing refs from another terminal, another screen, or stale - * refs violates this precondition. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param selection Selection snapshot to order - * @param desired Desired endpoint order - * @param[out] out_selection On success, receives the ordered selection - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, desired order, or output pointer - * are invalid - * - * @ingroup terminal - */ -GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( - GhosttyTerminal terminal, - const GhosttySelection* selection, - GhosttySelectionOrder desired, - GhosttySelection* out_selection); - -/** - * Test whether a terminal point is inside a selection snapshot. - * - * This uses the same selection semantics as the terminal, including - * rectangular/block selections and linear selections spanning multiple rows. - * - * The selection's start and end grid refs must both be valid untracked - * snapshots for the given terminal's currently active screen. In practice, - * they must come from that terminal and screen, and no mutating terminal call - * may have occurred since the refs were produced or reconstructed from - * tracked refs. Passing refs from another terminal, another screen, or stale - * refs violates this precondition. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param selection Selection snapshot to inspect - * @param point Point to test for containment - * @param[out] out_contains On success, receives whether point is inside selection - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, point, or output pointer are invalid - * - * @ingroup terminal - */ -GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( - GhosttyTerminal terminal, - const GhosttySelection* selection, - GhosttyPoint point, - bool* out_contains); - /** * Resolve a point in the terminal grid to a grid reference. * diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 0b75fe81b..fd009fce4 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -170,10 +170,10 @@ pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; -pub const terminal_selection_adjust = terminal.selection_adjust; -pub const terminal_selection_order = terminal.selection_order; -pub const terminal_selection_ordered = terminal.selection_ordered; -pub const terminal_selection_contains = terminal.selection_contains; +pub const terminal_selection_adjust = selection.adjust; +pub const terminal_selection_order = selection.order; +pub const terminal_selection_ordered = selection.ordered; +pub const terminal_selection_contains = selection.contains; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index c4c6284e6..3d2581310 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -1,5 +1,16 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); const grid_ref = @import("grid_ref.zig"); +const point = @import("../point.zig"); const Selection = @import("../Selection.zig"); +const Result = @import("result.zig").Result; +const terminal_c = @import("terminal.zig"); + +const log = std.log.scoped(.selection_c); + +pub const Adjustment = Selection.Adjustment; +pub const Order = Selection.Order; /// C: GhosttySelection pub const CSelection = extern struct { @@ -22,3 +33,85 @@ pub const CSelection = extern struct { }; } }; + +pub fn adjust( + terminal: terminal_c.Terminal, + selection: ?*CSelection, + adjustment: Selection.Adjustment, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Selection.Adjustment, @intFromEnum(adjustment)) catch { + log.warn("terminal_selection_adjust invalid adjustment value={d}", .{@intFromEnum(adjustment)}); + return .invalid_value; + }; + } + + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel_ptr = selection orelse return .invalid_value; + var sel = sel_ptr.toZig() orelse return .invalid_value; + sel.adjust(t.screens.active, adjustment); + sel_ptr.* = .fromZig(sel); + return .success; +} + +pub fn order( + terminal: terminal_c.Terminal, + selection: ?*const CSelection, + out_order: ?*Selection.Order, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_order orelse return .invalid_value; + if (!valid(t, sel)) return .invalid_value; + + out.* = sel.order(t.screens.active); + return .success; +} + +pub fn ordered( + terminal: terminal_c.Terminal, + selection: ?*const CSelection, + desired: Selection.Order, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Selection.Order, @intFromEnum(desired)) catch { + log.warn("terminal_selection_ordered invalid desired value={d}", .{@intFromEnum(desired)}); + return .invalid_value; + }; + } + + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_selection orelse return .invalid_value; + if (!valid(t, sel)) return .invalid_value; + + out.* = .fromZig(sel.ordered(t.screens.active, desired)); + return .success; +} + +pub fn contains( + terminal: terminal_c.Terminal, + selection: ?*const CSelection, + pt: point.Point.C, + out_contains: ?*bool, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_contains orelse return .invalid_value; + if (!valid(t, sel)) return .invalid_value; + + const screen = t.screens.active; + const pin = screen.pages.pin(.fromC(pt)) orelse return .invalid_value; + out.* = sel.contains(screen, pin); + return .success; +} + +fn valid(t: *terminal_c.ZigTerminal, sel: Selection) bool { + const screen = t.screens.active; + return screen.pages.pointFromPin(.screen, sel.start()) != null and + screen.pages.pointFromPin(.screen, sel.end()) != null; +} diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 6dc8f9e90..6a47bdf22 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -3,11 +3,10 @@ const testing = std.testing; const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; -const ZigTerminal = @import("../Terminal.zig"); +pub const ZigTerminal = @import("../Terminal.zig"); const Stream = @import("../stream_terminal.zig").Stream; const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); -const Selection = @import("../Selection.zig"); const apc = @import("../apc.zig"); const kitty = @import("../kitty/key.zig"); const kitty_gfx_c = @import("kitty_graphics.zig"); @@ -211,6 +210,10 @@ const Effects = struct { /// C: GhosttyTerminal pub const Terminal = ?*TerminalWrapper; +pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal { + return (terminal_ orelse return null).terminal; +} + /// C: GhosttyTerminalOptions pub const Options = extern struct { cols: size.CellCountInt, @@ -665,88 +668,6 @@ pub fn get_multi( return .success; } -pub fn selection_adjust( - terminal_: Terminal, - selection: ?*selection_c.CSelection, - adjustment: Selection.Adjustment, -) callconv(lib.calling_conv) Result { - if (comptime std.debug.runtime_safety) { - _ = std.meta.intToEnum(Selection.Adjustment, @intFromEnum(adjustment)) catch { - log.warn("terminal_selection_adjust invalid adjustment value={d}", .{@intFromEnum(adjustment)}); - return .invalid_value; - }; - } - - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const sel_ptr = selection orelse return .invalid_value; - var sel = sel_ptr.toZig() orelse return .invalid_value; - sel.adjust(t.screens.active, adjustment); - sel_ptr.* = .fromZig(sel); - return .success; -} - -pub fn selection_order( - terminal_: Terminal, - selection: ?*const selection_c.CSelection, - out_order: ?*Selection.Order, -) callconv(lib.calling_conv) Result { - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const sel = (selection orelse return .invalid_value).toZig() orelse - return .invalid_value; - const out = out_order orelse return .invalid_value; - if (!selectionValid(t, sel)) return .invalid_value; - - out.* = sel.order(t.screens.active); - return .success; -} - -pub fn selection_ordered( - terminal_: Terminal, - selection: ?*const selection_c.CSelection, - desired: Selection.Order, - out_selection: ?*selection_c.CSelection, -) callconv(lib.calling_conv) Result { - if (comptime std.debug.runtime_safety) { - _ = std.meta.intToEnum(Selection.Order, @intFromEnum(desired)) catch { - log.warn("terminal_selection_ordered invalid desired value={d}", .{@intFromEnum(desired)}); - return .invalid_value; - }; - } - - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const sel = (selection orelse return .invalid_value).toZig() orelse - return .invalid_value; - const out = out_selection orelse return .invalid_value; - if (!selectionValid(t, sel)) return .invalid_value; - - out.* = .fromZig(sel.ordered(t.screens.active, desired)); - return .success; -} - -pub fn selection_contains( - terminal_: Terminal, - selection: ?*const selection_c.CSelection, - pt: point.Point.C, - out_contains: ?*bool, -) callconv(lib.calling_conv) Result { - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const sel = (selection orelse return .invalid_value).toZig() orelse - return .invalid_value; - const out = out_contains orelse return .invalid_value; - if (!selectionValid(t, sel)) return .invalid_value; - - const screen = t.screens.active; - const pin = screen.pages.pin(.fromC(pt)) orelse return .invalid_value; - out.* = sel.contains(screen, pin); - return .success; -} - -fn selectionValid(t: *ZigTerminal, sel: Selection) bool { - const screen = t.screens.active; - return screen.pages.pointFromPin(.screen, sel.start()) != null and - screen.pages.pointFromPin(.screen, sel.end()) != null; -} - fn getTyped( terminal_: Terminal, comptime data: TerminalData, @@ -1503,11 +1424,11 @@ test "selection_adjust mutates snapshot end" { .start = start_ref, .end = end_ref, }; - try testing.expectEqual(Result.success, selection_adjust(t, &sel, .right)); + try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .right)); try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); try testing.expectEqual(@as(u16, 2), sel.end.toPin().?.x); - try testing.expectEqual(Result.success, selection_adjust(t, &sel, .left)); + try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .left)); try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); @@ -1515,7 +1436,7 @@ test "selection_adjust mutates snapshot end" { .start = end_ref, .end = start_ref, }; - try testing.expectEqual(Result.success, selection_adjust(t, &sel, .right)); + try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .right)); try testing.expectEqual(@as(u16, 1), sel.start.toPin().?.x); try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); } @@ -1553,19 +1474,19 @@ test "selection_order and selection_ordered" { .rectangle = true, }; - var order: Selection.Order = undefined; - try testing.expectEqual(Result.success, selection_order(t, &sel, &order)); - try testing.expectEqual(Selection.Order.mirrored_forward, order); + var order: selection_c.Order = undefined; + try testing.expectEqual(Result.success, selection_c.order(t, &sel, &order)); + try testing.expectEqual(selection_c.Order.mirrored_forward, order); var out: selection_c.CSelection = undefined; - try testing.expectEqual(Result.success, selection_ordered(t, &sel, .forward, &out)); + try testing.expectEqual(Result.success, selection_c.ordered(t, &sel, .forward, &out)); try testing.expectEqual(@as(u16, 1), out.start.toPin().?.x); try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y); try testing.expectEqual(@as(u16, 3), out.end.toPin().?.x); try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y); try testing.expect(out.rectangle); - try testing.expectEqual(Result.success, selection_ordered(t, &sel, .reverse, &out)); + try testing.expectEqual(Result.success, selection_c.ordered(t, &sel, .reverse, &out)); try testing.expectEqual(@as(u16, 3), out.start.toPin().?.x); try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y); try testing.expectEqual(@as(u16, 1), out.end.toPin().?.x); @@ -1606,13 +1527,13 @@ test "selection_contains" { }; var contains: bool = undefined; - try testing.expectEqual(Result.success, selection_contains(t, &linear, .{ + try testing.expectEqual(Result.success, selection_c.contains(t, &linear, .{ .tag = .active, .value = .{ .active = .{ .x = 4, .y = 0 } }, }, &contains)); try testing.expect(contains); - try testing.expectEqual(Result.success, selection_contains(t, &linear, .{ + try testing.expectEqual(Result.success, selection_c.contains(t, &linear, .{ .tag = .active, .value = .{ .active = .{ .x = 2, .y = 0 } }, }, &contains)); @@ -1624,7 +1545,7 @@ test "selection_contains" { .rectangle = true, }; - try testing.expectEqual(Result.success, selection_contains(t, &rectangle, .{ + try testing.expectEqual(Result.success, selection_c.contains(t, &rectangle, .{ .tag = .active, .value = .{ .active = .{ .x = 2, .y = 0 } }, }, &contains)); @@ -1644,9 +1565,9 @@ test "selection_order invalid values" { )); defer free(t); - var order: Selection.Order = undefined; - try testing.expectEqual(Result.invalid_value, selection_order(null, null, &order)); - try testing.expectEqual(Result.invalid_value, selection_order(t, null, &order)); + var order: selection_c.Order = undefined; + try testing.expectEqual(Result.invalid_value, selection_c.order(null, null, &order)); + try testing.expectEqual(Result.invalid_value, selection_c.order(t, null, &order)); } test "grid_ref" { From ae839393d9b0a6d6776e816e8a9193c3d6875850 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:21:44 -0700 Subject: [PATCH 08/14] libghostty: add Selection equal and validate --- include/ghostty/vt/selection.h | 47 ++++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/c/main.zig | 2 + src/terminal/c/selection.zig | 30 +++++++++++ src/terminal/c/terminal.zig | 91 ++++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index e397e9a5d..d050f74ab 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -270,6 +270,53 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( GhosttyPoint point, bool* out_contains); +/** + * Test whether two selection snapshots are equal. + * + * Equality uses the terminal's internal selection semantics: both endpoint + * pins must match and both selections must have the same rectangular/block + * state. This avoids requiring callers to compare raw GhosttyGridRef internals. + * + * Both selections' start and end grid refs must be valid untracked snapshots + * for the given terminal's currently active screen. In practice, they must + * come from that terminal and screen, and no mutating terminal call may have + * occurred since the refs were produced or reconstructed from tracked refs. + * Passing refs from another terminal, another screen, or stale refs returns + * GHOSTTY_INVALID_VALUE. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param a First selection snapshot to compare + * @param b Second selection snapshot to compare + * @param[out] out_equal On success, receives whether the selections are equal + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selections, selection references, or output pointer are invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_equal( + GhosttyTerminal terminal, + const GhosttySelection* a, + const GhosttySelection* b, + bool* out_equal); + +/** + * Validate that a selection snapshot is representable for a terminal. + * + * A valid selection has both endpoint grid refs resolved in the terminal's + * currently active screen/page list. Malformed refs, stale refs, refs from + * another terminal, or refs from an inactive screen return GHOSTTY_INVALID_VALUE. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to validate + * @return GHOSTTY_SUCCESS if the selection is valid for the terminal's active + * screen/page list, otherwise GHOSTTY_INVALID_VALUE + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_validate( + GhosttyTerminal terminal, + const GhosttySelection* selection); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 6e3ff18cd..68aa4db63 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -243,6 +243,8 @@ comptime { @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); + @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); + @export(&c.terminal_selection_validate, .{ .name = "ghostty_terminal_selection_validate" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index fd009fce4..86898e7b9 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -174,6 +174,8 @@ pub const terminal_selection_adjust = selection.adjust; pub const terminal_selection_order = selection.order; pub const terminal_selection_ordered = selection.ordered; pub const terminal_selection_contains = selection.contains; +pub const terminal_selection_equal = selection.equal; +pub const terminal_selection_validate = selection.validate; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 3d2581310..a5767609f 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -110,6 +110,36 @@ pub fn contains( return .success; } +pub fn equal( + terminal: terminal_c.Terminal, + a: ?*const CSelection, + b: ?*const CSelection, + out_equal: ?*bool, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel_a = (a orelse return .invalid_value).toZig() orelse + return .invalid_value; + const sel_b = (b orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_equal orelse return .invalid_value; + if (!valid(t, sel_a) or !valid(t, sel_b)) return .invalid_value; + + out.* = sel_a.eql(sel_b); + return .success; +} + +pub fn validate( + terminal: terminal_c.Terminal, + selection: ?*const CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + if (!valid(t, sel)) return .invalid_value; + + return .success; +} + fn valid(t: *terminal_c.ZigTerminal, sel: Selection) bool { const screen = t.screens.active; return screen.pages.pointFromPin(.screen, sel.start()) != null and diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 6a47bdf22..3114f92ad 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1552,6 +1552,97 @@ test "selection_contains" { try testing.expect(contains); } +test "selection_equal and selection_validate" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var other_t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &other_t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(other_t); + + vt_write(t, "Hello", 5); + vt_write(other_t, "Hello", 5); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &end_ref)); + + var other_end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 2, .y = 0 } }, + }, &other_end_ref)); + + var cross_terminal_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(other_t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &cross_terminal_ref)); + + const sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + const equal_sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + const different_endpoint: selection_c.CSelection = .{ + .start = start_ref, + .end = other_end_ref, + }; + const different_rectangle: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + .rectangle = true, + }; + const cross_terminal: selection_c.CSelection = .{ + .start = start_ref, + .end = cross_terminal_ref, + }; + + try testing.expectEqual(Result.success, selection_c.validate(t, &sel)); + try testing.expectEqual(Result.invalid_value, selection_c.validate(t, &cross_terminal)); + + var equal: bool = undefined; + try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &equal_sel, &equal)); + try testing.expect(equal); + + try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_endpoint, &equal)); + try testing.expect(!equal); + + try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_rectangle, &equal)); + try testing.expect(!equal); + + try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &cross_terminal, &equal)); + try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &equal_sel, null)); +} + test "selection_order invalid values" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 7b49d1f12928908de022c00ef5dbc099a66517fb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:26:24 -0700 Subject: [PATCH 09/14] terminal: PageList.reset needs to reset page serial mins --- src/terminal/PageList.zig | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 89fdaec1f..8e5cd1934 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -658,6 +658,11 @@ pub fn deinit(self: *PageList) void { pub fn reset(self: *PageList) void { defer self.assertIntegrity(); + // Invalidate all external page refs to the previous list. The reset below + // rebuilds the page list from the pools, so old untracked refs must be + // rejected before any validation attempts to inspect their node pointers. + self.page_serial_min = self.page_serial; + // We need enough pages/nodes to keep our active area. This should // never fail since we by definition have allocated a page already // that fits our size but I'm not confident to make that assertion. @@ -13543,6 +13548,30 @@ test "PageList reset" { }, s.getTopLeft(.active)); } +test "PageList reset invalidates stale untracked refs even if node memory is reused" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const old_serial = s.pages.first.?.serial; + try testing.expect(old_serial >= s.page_serial_min); + try testing.expect(old_serial < s.page_serial); + + s.reset(); + + // The important safety property is that stale serials are rejected before + // the node pointer is inspected. Reset rebuilds the page list from the + // pools, so old untracked refs may contain node pointers that are no + // longer safe to dereference. + try testing.expect(old_serial < s.page_serial_min); + + const new_serial = s.pages.first.?.serial; + try testing.expect(new_serial >= s.page_serial_min); + try testing.expect(new_serial < s.page_serial); +} + test "PageList reset across two pages" { const testing = std.testing; const alloc = testing.allocator; From 847b8afc872110f6cfd0c0f4690133904a06da16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 18:31:07 -0700 Subject: [PATCH 10/14] libghostty: remove selection validation, way too expensive --- include/ghostty/vt/selection.h | 38 +++++++++++----------------------- src/lib_vt.zig | 1 - src/terminal/c/main.zig | 1 - src/terminal/c/selection.zig | 24 +-------------------- src/terminal/c/terminal.zig | 8 +++---- 5 files changed, 16 insertions(+), 56 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index d050f74ab..f71235a5a 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -178,7 +178,8 @@ typedef enum GHOSTTY_ENUM_TYPED { * @param selection Selection snapshot to adjust in place * @param adjustment The adjustment operation to apply * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, or adjustment are invalid + * selection, or adjustment are invalid. Selection reference validity + * is a precondition and is not checked. * * @ingroup selection */ @@ -201,7 +202,8 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( * @param selection Selection snapshot to inspect * @param[out] out_order On success, receives the selection order * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, or output pointer are invalid + * selection, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. * * @ingroup selection */ @@ -231,8 +233,8 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( * @param desired Desired endpoint order * @param[out] out_selection On success, receives the ordered selection * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, desired order, or output pointer - * are invalid + * selection, desired order, or output pointer are invalid. Selection + * reference validity is a precondition and is not checked. * * @ingroup selection */ @@ -260,7 +262,8 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( * @param point Point to test for containment * @param[out] out_contains On success, receives whether point is inside selection * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, point, or output pointer are invalid + * selection, point, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. * * @ingroup selection */ @@ -281,15 +284,16 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( * for the given terminal's currently active screen. In practice, they must * come from that terminal and screen, and no mutating terminal call may have * occurred since the refs were produced or reconstructed from tracked refs. - * Passing refs from another terminal, another screen, or stale refs returns - * GHOSTTY_INVALID_VALUE. + * Passing refs from another terminal, another screen, or stale refs violates + * this precondition. * * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) * @param a First selection snapshot to compare * @param b Second selection snapshot to compare * @param[out] out_equal On success, receives whether the selections are equal * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selections, selection references, or output pointer are invalid + * selections, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. * * @ingroup selection */ @@ -299,24 +303,6 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_equal( const GhosttySelection* b, bool* out_equal); -/** - * Validate that a selection snapshot is representable for a terminal. - * - * A valid selection has both endpoint grid refs resolved in the terminal's - * currently active screen/page list. Malformed refs, stale refs, refs from - * another terminal, or refs from an inactive screen return GHOSTTY_INVALID_VALUE. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param selection Selection snapshot to validate - * @return GHOSTTY_SUCCESS if the selection is valid for the terminal's active - * screen/page list, otherwise GHOSTTY_INVALID_VALUE - * - * @ingroup selection - */ -GHOSTTY_API GhosttyResult ghostty_terminal_selection_validate( - GhosttyTerminal terminal, - const GhosttySelection* selection); - /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 68aa4db63..cf3c2f820 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -244,7 +244,6 @@ comptime { @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); - @export(&c.terminal_selection_validate, .{ .name = "ghostty_terminal_selection_validate" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 86898e7b9..19294d4a0 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -175,7 +175,6 @@ pub const terminal_selection_order = selection.order; pub const terminal_selection_ordered = selection.ordered; pub const terminal_selection_contains = selection.contains; pub const terminal_selection_equal = selection.equal; -pub const terminal_selection_validate = selection.validate; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index a5767609f..5734e9fb0 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -63,7 +63,6 @@ pub fn order( const sel = (selection orelse return .invalid_value).toZig() orelse return .invalid_value; const out = out_order orelse return .invalid_value; - if (!valid(t, sel)) return .invalid_value; out.* = sel.order(t.screens.active); return .success; @@ -86,7 +85,6 @@ pub fn ordered( const sel = (selection orelse return .invalid_value).toZig() orelse return .invalid_value; const out = out_selection orelse return .invalid_value; - if (!valid(t, sel)) return .invalid_value; out.* = .fromZig(sel.ordered(t.screens.active, desired)); return .success; @@ -102,7 +100,6 @@ pub fn contains( const sel = (selection orelse return .invalid_value).toZig() orelse return .invalid_value; const out = out_contains orelse return .invalid_value; - if (!valid(t, sel)) return .invalid_value; const screen = t.screens.active; const pin = screen.pages.pin(.fromC(pt)) orelse return .invalid_value; @@ -116,32 +113,13 @@ pub fn equal( b: ?*const CSelection, out_equal: ?*bool, ) callconv(lib.calling_conv) Result { - const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + _ = terminal_c.zigTerminal(terminal) orelse return .invalid_value; const sel_a = (a orelse return .invalid_value).toZig() orelse return .invalid_value; const sel_b = (b orelse return .invalid_value).toZig() orelse return .invalid_value; const out = out_equal orelse return .invalid_value; - if (!valid(t, sel_a) or !valid(t, sel_b)) return .invalid_value; out.* = sel_a.eql(sel_b); return .success; } - -pub fn validate( - terminal: terminal_c.Terminal, - selection: ?*const CSelection, -) callconv(lib.calling_conv) Result { - const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; - const sel = (selection orelse return .invalid_value).toZig() orelse - return .invalid_value; - if (!valid(t, sel)) return .invalid_value; - - return .success; -} - -fn valid(t: *terminal_c.ZigTerminal, sel: Selection) bool { - const screen = t.screens.active; - return screen.pages.pointFromPin(.screen, sel.start()) != null and - screen.pages.pointFromPin(.screen, sel.end()) != null; -} diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 3114f92ad..939b7a673 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1552,7 +1552,7 @@ test "selection_contains" { try testing.expect(contains); } -test "selection_equal and selection_validate" { +test "selection_equal" { var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib.alloc.test_allocator, @@ -1626,9 +1626,6 @@ test "selection_equal and selection_validate" { .end = cross_terminal_ref, }; - try testing.expectEqual(Result.success, selection_c.validate(t, &sel)); - try testing.expectEqual(Result.invalid_value, selection_c.validate(t, &cross_terminal)); - var equal: bool = undefined; try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &equal_sel, &equal)); try testing.expect(equal); @@ -1639,7 +1636,8 @@ test "selection_equal and selection_validate" { try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_rectangle, &equal)); try testing.expect(!equal); - try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &cross_terminal, &equal)); + try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &cross_terminal, &equal)); + try testing.expect(!equal); try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &equal_sel, null)); } From cc48312c08a0f0e08f77a8df74c15e9e367ce70e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 12:53:41 -0700 Subject: [PATCH 11/14] libghostty: selection word/line/output/all helpers --- include/ghostty/vt/selection.h | 132 ++++++++++++++++++++++++++ src/config/Config.zig | 27 +----- src/lib_vt.zig | 4 + src/terminal/Screen.zig | 23 ++--- src/terminal/c/main.zig | 4 + src/terminal/c/selection.zig | 125 ++++++++++++++++++++++++ src/terminal/c/terminal.zig | 66 +++++++++++++ src/terminal/c/types.zig | 53 ++++++----- src/terminal/selection_codepoints.zig | 31 ++++++ 9 files changed, 405 insertions(+), 60 deletions(-) create mode 100644 src/terminal/selection_codepoints.zig diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index f71235a5a..89a722673 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -78,6 +79,57 @@ typedef struct { bool rectangle; } GhosttySelection; +/** + * Options for deriving a word selection from a terminal grid reference. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's + * default word-boundary codepoints are used. If boundary_codepoints_len is + * non-zero, boundary_codepoints must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordOptions). */ + size_t size; + + /** Grid reference under which to derive the word selection. */ + GhosttyGridRef ref; + + /** Optional word-boundary codepoints as uint32_t scalar values. */ + const uint32_t* boundary_codepoints; + + /** Number of entries in boundary_codepoints. */ + size_t boundary_codepoints_len; +} GhosttyTerminalSelectWordOptions; + +/** + * Options for deriving a line selection from a terminal grid reference. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If whitespace is NULL and whitespace_len is 0, Ghostty's default line-trim + * whitespace codepoints are used. If whitespace_len is non-zero, whitespace + * must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectLineOptions). */ + size_t size; + + /** Grid reference under which to derive the line selection. */ + GhosttyGridRef ref; + + /** Optional codepoints to trim from the start and end of the line. */ + const uint32_t* whitespace; + + /** Number of entries in whitespace. */ + size_t whitespace_len; + + /** Whether semantic prompt state changes should bound the line selection. */ + bool semantic_prompt_boundary; +} GhosttyTerminalSelectLineOptions; + /** * Ordering of a selection's endpoints in terminal coordinates. * @@ -158,6 +210,86 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionAdjust; +/** + * Derive a word selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Word-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has + * no selectable word content, or GHOSTTY_INVALID_VALUE if the + * terminal, options, ref, codepoint pointer, or output pointer are + * invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_word( + GhosttyTerminal terminal, + const GhosttyTerminalSelectWordOptions* options, + GhosttySelection* out_selection); + +/** + * Derive a line selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Line-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has + * no selectable line content, or GHOSTTY_INVALID_VALUE if the + * terminal, options, ref, codepoint pointer, or output pointer are + * invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_line( + GhosttyTerminal terminal, + const GhosttyTerminalSelectLineOptions* options, + GhosttySelection* out_selection); + +/** + * Derive a selection snapshot covering all selectable terminal content. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no + * selectable content, or GHOSTTY_INVALID_VALUE if the terminal or + * output pointer is invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_all( + GhosttyTerminal terminal, + GhosttySelection* out_selection); + +/** + * Derive a command-output selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param ref Grid reference within command output to select + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref is + * not selectable command output, or GHOSTTY_INVALID_VALUE if the + * terminal, ref, or output pointer is invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_output( + GhosttyTerminal terminal, + GhosttyGridRef ref, + GhosttySelection* out_selection); + /** * Adjust a selection snapshot using terminal selection semantics. * diff --git a/src/config/Config.zig b/src/config/Config.zig index 9e6e5629c..380155127 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -49,6 +49,7 @@ const string = @import("string.zig"); const terminal = struct { const CursorStyle = @import("../terminal/cursor.zig").Style; const color = @import("../terminal/color.zig"); + const selection_codepoints = @import("../terminal/selection_codepoints.zig"); const style = @import("../terminal/style.zig"); const x11_color = @import("../terminal/x11_color.zig"); }; @@ -6149,32 +6150,8 @@ pub const RepeatableString = struct { pub const SelectionWordChars = struct { const Self = @This(); - /// Default boundary characters: ` \t'"│`|:;,()[]{}<>$` - const default_codepoints = [_]u21{ - 0, // null - ' ', // space - '\t', // tab - '\'', // single quote - '"', // double quote - '│', // U+2502 box drawing - '`', // backtick - '|', // pipe - ':', // colon - ';', // semicolon - ',', // comma - '(', // left paren - ')', // right paren - '[', // left bracket - ']', // right bracket - '{', // left brace - '}', // right brace - '<', // less than - '>', // greater than - '$', // dollar - }; - /// The parsed codepoints. Always includes null (U+0000) at index 0. - codepoints: []const u21 = &default_codepoints, + codepoints: []const u21 = &terminal.selection_codepoints.default_word_boundaries, pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { const value = input orelse return error.ValueRequired; diff --git a/src/lib_vt.zig b/src/lib_vt.zig index cf3c2f820..543c5b447 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -239,6 +239,10 @@ comptime { @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); + @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); + @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); + @export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" }); + @export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" }); @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ac53a2d72..becda78b7 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -13,6 +13,7 @@ const tripwire = @import("../tripwire.zig"); const unicode = @import("../unicode/main.zig"); const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); +const selection_codepoints = @import("selection_codepoints.zig"); const StringMap = @import("StringMap.zig"); const ScreenFormatter = @import("formatter.zig").ScreenFormatter; const osc = @import("osc.zig"); @@ -2516,7 +2517,7 @@ pub const SelectLine = struct { /// These are the codepoints to consider whitespace to trim /// from the ends of the selection. - whitespace: ?[]const u21 = &.{ 0, ' ', '\t' }, + whitespace: ?[]const u21 = &selection_codepoints.default_line_whitespace, /// If true, line selection will consider semantic prompt /// state changing a boundary. State changing is ANY state @@ -2652,10 +2653,10 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (!cell.hasText()) continue; // Non-empty means we found it. - const this_whitespace = std.mem.indexOfAny( + const this_whitespace = std.mem.indexOfScalar( u21, whitespace, - &[_]u21{cell.content.codepoint}, + cell.content.codepoint, ) != null; if (this_whitespace) continue; @@ -2674,10 +2675,10 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (!cell.hasText()) continue; // Non-empty means we found it. - const this_whitespace = std.mem.indexOfAny( + const this_whitespace = std.mem.indexOfScalar( u21, whitespace, - &[_]u21{cell.content.codepoint}, + cell.content.codepoint, ) != null; if (this_whitespace) continue; @@ -2798,10 +2799,10 @@ pub fn selectWord( if (!start_cell.hasText()) return null; // Determine if we are a boundary or not to determine what our boundary is. - const expect_boundary = std.mem.indexOfAny( + const expect_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, - &[_]u21{start_cell.content.codepoint}, + start_cell.content.codepoint, ) != null; // Go forwards to find our end boundary @@ -2816,10 +2817,10 @@ pub fn selectWord( if (!cell.hasText()) break :end prev; // If we do not match our expected set, we hit a boundary - const this_boundary = std.mem.indexOfAny( + const this_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, - &[_]u21{cell.content.codepoint}, + cell.content.codepoint, ) != null; if (this_boundary != expect_boundary) break :end prev; @@ -2853,10 +2854,10 @@ pub fn selectWord( if (!cell.hasText()) break :start prev; // If we do not match our expected set, we hit a boundary - const this_boundary = std.mem.indexOfAny( + const this_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, - &[_]u21{cell.content.codepoint}, + cell.content.codepoint, ) != null; if (this_boundary != expect_boundary) break :start prev; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 19294d4a0..1cd7e0231 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -170,6 +170,10 @@ pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; +pub const terminal_select_word = selection.word; +pub const terminal_select_line = selection.line; +pub const terminal_select_all = selection.all; +pub const terminal_select_output = selection.output; pub const terminal_selection_adjust = selection.adjust; pub const terminal_selection_order = selection.order; pub const terminal_selection_ordered = selection.ordered; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 5734e9fb0..ea1eea473 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -3,6 +3,7 @@ const testing = std.testing; const lib = @import("../lib.zig"); const grid_ref = @import("grid_ref.zig"); const point = @import("../point.zig"); +const selection_codepoints = @import("../selection_codepoints.zig"); const Selection = @import("../Selection.zig"); const Result = @import("result.zig").Result; const terminal_c = @import("terminal.zig"); @@ -34,6 +35,130 @@ pub const CSelection = extern struct { } }; +/// C: GhosttyTerminalSelectWordOptions +pub const SelectWordOptions = extern struct { + size: usize = @sizeOf(SelectWordOptions), + ref: grid_ref.CGridRef, + boundary_codepoints: ?[*]const u32 = null, + boundary_codepoints_len: usize = 0, +}; + +/// C: GhosttyTerminalSelectLineOptions +pub const SelectLineOptions = extern struct { + size: usize = @sizeOf(SelectLineOptions), + ref: grid_ref.CGridRef, + whitespace: ?[*]const u32 = null, + whitespace_len: usize = 0, + semantic_prompt_boundary: bool = false, +}; + +pub fn word( + terminal: terminal_c.Terminal, + options: ?*const SelectWordOptions, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const opts = options orelse return .invalid_value; + if (opts.size < @sizeOf(SelectWordOptions)) return .invalid_value; + const out = out_selection orelse return .invalid_value; + + const boundary_codepoints = codepointSlice( + opts.boundary_codepoints, + opts.boundary_codepoints_len, + ) catch return .invalid_value; + + const screen = t.screens.active; + const pin = opts.ref.toPin() orelse return .invalid_value; + out.* = .fromZig(screen.selectWord( + pin, + boundary_codepoints orelse &selection_codepoints.default_word_boundaries, + ) orelse + return .no_value); + return .success; +} + +pub fn line( + terminal: terminal_c.Terminal, + options: ?*const SelectLineOptions, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const opts = options orelse return .invalid_value; + if (opts.size < @sizeOf(SelectLineOptions)) return .invalid_value; + const out = out_selection orelse return .invalid_value; + + const whitespace = codepointSlice( + opts.whitespace, + opts.whitespace_len, + ) catch return .invalid_value; + + const screen = t.screens.active; + const pin = opts.ref.toPin() orelse return .invalid_value; + out.* = .fromZig(screen.selectLine(.{ + .pin = pin, + .whitespace = whitespace orelse &selection_codepoints.default_line_whitespace, + .semantic_prompt_boundary = opts.semantic_prompt_boundary, + }) orelse return .no_value); + return .success; +} + +pub fn all( + terminal: terminal_c.Terminal, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const out = out_selection orelse return .invalid_value; + + out.* = .fromZig(t.screens.active.selectAll() orelse return .no_value); + return .success; +} + +pub fn output( + terminal: terminal_c.Terminal, + ref: grid_ref.CGridRef, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const out = out_selection orelse return .invalid_value; + + const screen = t.screens.active; + const pin = ref.toPin() orelse return .invalid_value; + out.* = .fromZig(screen.selectOutput(pin) orelse return .no_value); + return .success; +} + +/// Return the borrowed C array of `uint32_t` codepoints as a `[]const u21`. +/// +/// `NULL + len 0` returns null, which callers treat as “use the API default +/// set.” A non-null pointer with `len 0` returns an empty slice, meaning “use an +/// explicitly empty set.” A non-zero length requires a non-null pointer. +/// +/// This is intentionally zero-copy. In the C ABI, codepoints are `uint32_t`, +/// but selection internals use Zig's `u21` to represent valid Unicode scalar +/// values. Zig currently stores `u21` in the same size and alignment as `u32`, +/// so we assert that layout relationship and reinterpret the borrowed slice. +/// If Zig ever changes that representation, these comptime assertions fail +/// loudly rather than silently making this cast wrong. +fn codepointSlice( + ptr: ?[*]const u32, + len: usize, +) error{InvalidValue}!?[]const u21 { + comptime { + std.debug.assert(@sizeOf(u21) == @sizeOf(u32)); + std.debug.assert(@alignOf(u21) == @alignOf(u32)); + } + + if (len == 0) { + const p = ptr orelse return null; + _ = p; + return &.{}; + } + + const p = ptr orelse return error.InvalidValue; + const cps: [*]const u21 = @ptrCast(p); + return cps[0..len]; +} + pub fn adjust( terminal: terminal_c.Terminal, selection: ?*CSelection, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 939b7a673..6c7ce3ce5 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1393,6 +1393,72 @@ test "set and get selection" { try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out))); } +test "selection derivation helpers" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, " Hello \r\nWorld", 16); + + var out: selection_c.CSelection = undefined; + + var word_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 0 } }, + }, &word_ref)); + + var empty_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 20, .y = 0 } }, + }, &empty_ref)); + + var line_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &line_ref)); + + var word_opts: selection_c.SelectWordOptions = .{ + .ref = word_ref, + }; + try testing.expectEqual(Result.success, selection_c.word(t, &word_opts, &out)); + try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x); + + word_opts.ref = empty_ref; + try testing.expectEqual(Result.no_value, selection_c.word(t, &word_opts, &out)); + + var line_opts: selection_c.SelectLineOptions = .{ + .ref = line_ref, + }; + try testing.expectEqual(Result.success, selection_c.line(t, &line_opts, &out)); + try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x); + + try testing.expectEqual(Result.success, selection_c.all(t, &out)); + try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y); + try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y); + + try testing.expectEqual(Result.no_value, selection_c.output(t, line_ref, &out)); + + line_opts.size = @sizeOf(usize) - 1; + try testing.expectEqual(Result.invalid_value, selection_c.line(t, &line_opts, &out)); + try testing.expectEqual(Result.invalid_value, selection_c.word(t, null, &out)); + try testing.expectEqual(Result.invalid_value, selection_c.word(t, &word_opts, null)); +} + test "selection_adjust mutates snapshot end" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 500809d9c..8a6a7f927 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -20,30 +20,35 @@ const mouse_encode = @import("mouse_encode.zig"); const grid_ref = @import("grid_ref.zig"); /// All C API structs and their Ghostty C names. -pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{ - .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, - .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, - .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, - .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, - .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) }, - .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, - .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, - .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, - .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, - .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) }, - .{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) }, - .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) }, - .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, - .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, - .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, - .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, - .{ "GhosttyString", StructInfo.init(lib.String) }, - .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, - .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, - .{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) }, - .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, - .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, -}); +pub const structs: std.StaticStringMap(StructInfo) = structs: { + @setEvalBranchQuota(10_000); + break :structs .initComptime(.{ + .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, + .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, + .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, + .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, + .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) }, + .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, + .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, + .{ "GhosttyTerminalSelectWordOptions", StructInfo.init(selection.SelectWordOptions) }, + .{ "GhosttyTerminalSelectLineOptions", StructInfo.init(selection.SelectLineOptions) }, + .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, + .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, + .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) }, + .{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) }, + .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) }, + .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, + .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, + .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, + .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, + .{ "GhosttyString", StructInfo.init(lib.String) }, + .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, + .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, + .{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) }, + .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, + .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, + }); +}; /// The comptime-generated JSON string of all structs. pub const json: [:0]const u8 = json: { diff --git a/src/terminal/selection_codepoints.zig b/src/terminal/selection_codepoints.zig new file mode 100644 index 000000000..4c3184030 --- /dev/null +++ b/src/terminal/selection_codepoints.zig @@ -0,0 +1,31 @@ +// This file contains various default word boundaries used for +// selection logic. We put it in a separate file so that different +// subsystems can import it without introducing a number of +// dependencies. + +/// Default boundary characters for word selection: ` \t'"│`|:;,()[]{}<>$` +pub const default_word_boundaries = [_]u21{ + 0, // null + ' ', // space + '\t', // tab + '\'', // single quote + '"', // double quote + '│', // U+2502 box drawing + '`', // backtick + '|', // pipe + ':', // colon + ';', // semicolon + ',', // comma + '(', // left paren + ')', // right paren + '[', // left bracket + ']', // right bracket + '{', // left brace + '}', // right brace + '<', // less than + '>', // greater than + '$', // dollar +}; + +/// Default whitespace characters trimmed from line selections. +pub const default_line_whitespace = [_]u21{ 0, ' ', '\t' }; From e8f53539120e73e06c5bd82ab39dbf1499bf9311 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 13:47:32 -0700 Subject: [PATCH 12/14] example/c-vt-selection --- example/c-vt-selection/README.md | 18 +++++ example/c-vt-selection/build.zig | 42 +++++++++++ example/c-vt-selection/build.zig.zon | 24 +++++++ example/c-vt-selection/src/main.c | 102 +++++++++++++++++++++++++++ include/ghostty/vt/selection.h | 4 ++ 5 files changed, 190 insertions(+) create mode 100644 example/c-vt-selection/README.md create mode 100644 example/c-vt-selection/build.zig create mode 100644 example/c-vt-selection/build.zig.zon create mode 100644 example/c-vt-selection/src/main.c diff --git a/example/c-vt-selection/README.md b/example/c-vt-selection/README.md new file mode 100644 index 000000000..c88f7a11d --- /dev/null +++ b/example/c-vt-selection/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Selection + +This contains a simple example of how to use the `ghostty-vt` terminal, +grid reference, selection, and formatter APIs to derive selections such as a +word, semantic command line, command output, and all visible content. + +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 +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-selection/build.zig b/example/c-vt-selection/build.zig new file mode 100644 index 000000000..49f7c8cb3 --- /dev/null +++ b/example/c-vt-selection/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_selection", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-selection/build.zig.zon b/example/c-vt-selection/build.zig.zon new file mode 100644 index 000000000..d09800a51 --- /dev/null +++ b/example/c-vt-selection/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_selection, + .version = "0.0.0", + .fingerprint = 0xb2c2f1a828086fef, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-selection/src/main.c b/example/c-vt-selection/src/main.c new file mode 100644 index 000000000..ea638bbe8 --- /dev/null +++ b/example/c-vt-selection/src/main.c @@ -0,0 +1,102 @@ +#include +#include +#include +#include + +//! [selection-main] +static void vt_write(GhosttyTerminal terminal, const char *s) { + ghostty_terminal_vt_write(terminal, (const uint8_t *)s, strlen(s)); +} + +static GhosttyGridRef ref_at(GhosttyTerminal terminal, uint16_t x, uint16_t y) { + GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint point = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = x, .y = y } }, + }; + + GhosttyResult result = ghostty_terminal_grid_ref(terminal, point, &ref); + assert(result == GHOSTTY_SUCCESS); + return ref; +} + +static void print_selection( + GhosttyTerminal terminal, + const char *label, + const GhosttySelection *selection) { + GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions); + opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + opts.trim = true; + opts.selection = selection; + + GhosttyFormatter formatter; + GhosttyResult result = ghostty_formatter_terminal_new( + NULL, &formatter, terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + uint8_t *buf = NULL; + size_t len = 0; + result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len); + assert(result == GHOSTTY_SUCCESS); + + printf("%s: ", label); + fwrite(buf, 1, len, stdout); + printf("\n"); + + ghostty_free(NULL, buf, len); + ghostty_formatter_free(formatter); +} + +int main() { + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 8, + .max_scrollback = 0, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + // A realistic shell transcript with OSC 133 semantic prompt markers. + // Ghostty uses these markers to distinguish prompt/input from command + // output for semantic line and output selections. + vt_write(terminal, + "\033]133;A\007$ " // Prompt starts: "$ " + "\033]133;B\007git status" // Input starts: "git status" + "\033]133;C\007\r\n" // Output starts after Enter + "On branch main\r\n" + "nothing to commit, working tree clean"); + + GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection); + + // Double-click style word selection under the cursor. + GhosttyTerminalSelectWordOptions word = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordOptions); + word.ref = ref_at(terminal, 6, 0); // the "status" in "git status" + result = ghostty_terminal_select_word(terminal, &word, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "word", &selection); + + // Triple-click style line selection. With semantic prompt boundaries enabled, + // this selects only the input area rather than the leading "$ " prompt. + GhosttyTerminalSelectLineOptions line = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectLineOptions); + line.ref = ref_at(terminal, 2, 0); // the "git status" input area + line.semantic_prompt_boundary = true; + result = ghostty_terminal_select_line(terminal, &line, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "line", &selection); + + // Select exactly the command output for the command under the cursor. + result = ghostty_terminal_select_output( + terminal, ref_at(terminal, 0, 1), &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "output", &selection); + + // Select all visible content. + result = ghostty_terminal_select_all(terminal, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "all", &selection); + + ghostty_terminal_free(terminal); + return 0; +} +//! [selection-main] diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 89a722673..654396aef 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -30,6 +30,10 @@ extern "C" { * for the endpoints and reconstruct a GhosttySelection from fresh snapshots * when needed. * + * ## Examples + * + * @snippet c-vt-selection/src/main.c selection-main + * * @{ */ From eb777b8036d8c457ee181eab136858d1ca86aa88 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 13:51:43 -0700 Subject: [PATCH 13/14] libghostty: selectWordBetween in C --- example/c-vt-selection/src/main.c | 34 +++++++++++++++++ include/ghostty/vt/selection.h | 61 +++++++++++++++++++++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/selection.zig | 36 ++++++++++++++++++ src/terminal/c/terminal.zig | 24 ++++++++++++ src/terminal/c/types.zig | 1 + 7 files changed, 158 insertions(+) diff --git a/example/c-vt-selection/src/main.c b/example/c-vt-selection/src/main.c index ea638bbe8..83384ec15 100644 --- a/example/c-vt-selection/src/main.c +++ b/example/c-vt-selection/src/main.c @@ -76,6 +76,40 @@ int main() { assert(result == GHOSTTY_SUCCESS); print_selection(terminal, "word", &selection); + //! [selection-word-between] + // Double-click-and-drag style selection. Suppose the user double-clicks + // "git" and drags to "status". The pointer may pass over whitespace, so + // select the nearest word between the original click and current drag point + // in both directions, then combine the outer word bounds. + GhosttyGridRef click_ref = ref_at(terminal, 2, 0); // the "git" in "git status" + GhosttyGridRef drag_ref = ref_at(terminal, 6, 0); // the "status" in "git status" + + GhosttyTerminalSelectWordBetweenOptions start_word_opts = + GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordBetweenOptions); + start_word_opts.start = click_ref; + start_word_opts.end = drag_ref; + + GhosttySelection start_word = GHOSTTY_INIT_SIZED(GhosttySelection); + result = ghostty_terminal_select_word_between( + terminal, &start_word_opts, &start_word); + assert(result == GHOSTTY_SUCCESS); + + GhosttyTerminalSelectWordBetweenOptions end_word_opts = + GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordBetweenOptions); + end_word_opts.start = drag_ref; + end_word_opts.end = click_ref; + + GhosttySelection end_word = GHOSTTY_INIT_SIZED(GhosttySelection); + result = ghostty_terminal_select_word_between( + terminal, &end_word_opts, &end_word); + assert(result == GHOSTTY_SUCCESS); + + GhosttySelection drag_selection = GHOSTTY_INIT_SIZED(GhosttySelection); + drag_selection.start = start_word.start; + drag_selection.end = end_word.end; + print_selection(terminal, "double-click drag", &drag_selection); + //! [selection-word-between] + // Triple-click style line selection. With semantic prompt boundaries enabled, // this selects only the input area rather than the leading "$ " prompt. GhosttyTerminalSelectLineOptions line = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectLineOptions); diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 654396aef..52f1e09c2 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -107,6 +107,33 @@ typedef struct { size_t boundary_codepoints_len; } GhosttyTerminalSelectWordOptions; +/** + * Options for deriving the nearest word selection between two grid references. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's + * default word-boundary codepoints are used. If boundary_codepoints_len is + * non-zero, boundary_codepoints must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordBetweenOptions). */ + size_t size; + + /** Starting grid reference for the inclusive search range. */ + GhosttyGridRef start; + + /** Ending grid reference for the inclusive search range. */ + GhosttyGridRef end; + + /** Optional word-boundary codepoints as uint32_t scalar values. */ + const uint32_t* boundary_codepoints; + + /** Number of entries in boundary_codepoints. */ + size_t boundary_codepoints_len; +} GhosttyTerminalSelectWordBetweenOptions; + /** * Options for deriving a line selection from a terminal grid reference. * @@ -235,6 +262,40 @@ GHOSTTY_API GhosttyResult ghostty_terminal_select_word( const GhosttyTerminalSelectWordOptions* options, GhosttySelection* out_selection); +/** + * Derive the nearest word selection snapshot between two terminal grid refs. + * + * Starting at options->start, this searches toward options->end (inclusive) + * and returns the first selectable word found using Ghostty's word-selection + * rules. + * + * This is useful for implementing double-click-and-drag selection in a UI. If + * a user double-clicks one word and drags across spaces or punctuation toward + * another word, selecting only the word directly under the current pointer can + * flicker or collapse when the pointer is between words. Instead, ask for the + * nearest word between the original click and the drag point, ask again in the + * reverse direction, and combine the two word bounds into the drag selection. + * + * @snippet c-vt-selection/src/main.c selection-word-between + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Word-between-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no + * selectable word content between the valid refs, or + * GHOSTTY_INVALID_VALUE if the terminal, options, refs, codepoint + * pointer, or output pointer are invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_word_between( + GhosttyTerminal terminal, + const GhosttyTerminalSelectWordBetweenOptions* options, + GhosttySelection* out_selection); + /** * Derive a line selection snapshot from a terminal grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 543c5b447..291bf37f6 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -240,6 +240,7 @@ comptime { @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); + @export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" }); @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); @export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" }); @export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 1cd7e0231..3e776a0e4 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -171,6 +171,7 @@ pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; pub const terminal_select_word = selection.word; +pub const terminal_select_word_between = selection.word_between; pub const terminal_select_line = selection.line; pub const terminal_select_all = selection.all; pub const terminal_select_output = selection.output; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index ea1eea473..6bd8a9bb3 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -43,6 +43,15 @@ pub const SelectWordOptions = extern struct { boundary_codepoints_len: usize = 0, }; +/// C: GhosttyTerminalSelectWordBetweenOptions +pub const SelectWordBetweenOptions = extern struct { + size: usize = @sizeOf(SelectWordBetweenOptions), + start: grid_ref.CGridRef, + end: grid_ref.CGridRef, + boundary_codepoints: ?[*]const u32 = null, + boundary_codepoints_len: usize = 0, +}; + /// C: GhosttyTerminalSelectLineOptions pub const SelectLineOptions = extern struct { size: usize = @sizeOf(SelectLineOptions), @@ -77,6 +86,33 @@ pub fn word( return .success; } +pub fn word_between( + terminal: terminal_c.Terminal, + options: ?*const SelectWordBetweenOptions, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const opts = options orelse return .invalid_value; + if (opts.size < @sizeOf(SelectWordBetweenOptions)) return .invalid_value; + const out = out_selection orelse return .invalid_value; + + const boundary_codepoints = codepointSlice( + opts.boundary_codepoints, + opts.boundary_codepoints_len, + ) catch return .invalid_value; + + const screen = t.screens.active; + const start = opts.start.toPin() orelse return .invalid_value; + const end = opts.end.toPin() orelse return .invalid_value; + out.* = .fromZig(screen.selectWordBetween( + start, + end, + boundary_codepoints orelse &selection_codepoints.default_word_boundaries, + ) orelse + return .no_value); + return .success; +} + pub fn line( terminal: terminal_c.Terminal, options: ?*const SelectLineOptions, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 6c7ce3ce5..c3336129b 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1438,6 +1438,28 @@ test "selection derivation helpers" { word_opts.ref = empty_ref; try testing.expectEqual(Result.no_value, selection_c.word(t, &word_opts, &out)); + var between_start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 20, .y = 1 } }, + }, &between_start_ref)); + + var between_end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 1 } }, + }, &between_end_ref)); + + var word_between_opts: selection_c.SelectWordBetweenOptions = .{ + .start = between_start_ref, + .end = between_end_ref, + }; + try testing.expectEqual(Result.success, selection_c.word_between(t, &word_between_opts, &out)); + try testing.expectEqual(@as(u16, 0), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y); + try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y); + var line_opts: selection_c.SelectLineOptions = .{ .ref = line_ref, }; @@ -1457,6 +1479,8 @@ test "selection derivation helpers" { try testing.expectEqual(Result.invalid_value, selection_c.line(t, &line_opts, &out)); try testing.expectEqual(Result.invalid_value, selection_c.word(t, null, &out)); try testing.expectEqual(Result.invalid_value, selection_c.word(t, &word_opts, null)); + try testing.expectEqual(Result.invalid_value, selection_c.word_between(t, null, &out)); + try testing.expectEqual(Result.invalid_value, selection_c.word_between(t, &word_between_opts, null)); } test "selection_adjust mutates snapshot end" { diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 8a6a7f927..d9ece57ee 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -31,6 +31,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, .{ "GhosttyTerminalSelectWordOptions", StructInfo.init(selection.SelectWordOptions) }, + .{ "GhosttyTerminalSelectWordBetweenOptions", StructInfo.init(selection.SelectWordBetweenOptions) }, .{ "GhosttyTerminalSelectLineOptions", StructInfo.init(selection.SelectLineOptions) }, .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, From 2ce5db29ca162033e3cc570533184e13f3c01b53 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 13:56:54 -0700 Subject: [PATCH 14/14] libghostty: selection formatting --- include/ghostty/vt/formatter.h | 17 --- include/ghostty/vt/selection.h | 110 +++++++++++++++ include/ghostty/vt/types.h | 17 +++ src/lib_vt.zig | 2 + src/terminal/c/main.zig | 2 + src/terminal/c/selection.zig | 245 +++++++++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 17 deletions(-) diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h index 358e95f66..5cdcd11a3 100644 --- a/include/ghostty/vt/formatter.h +++ b/include/ghostty/vt/formatter.h @@ -32,23 +32,6 @@ extern "C" { * @{ */ -/** - * Output format. - * - * @ingroup formatter - */ -typedef enum GHOSTTY_ENUM_TYPED { - /** Plain text (no escape sequences). */ - GHOSTTY_FORMATTER_FORMAT_PLAIN, - - /** VT sequences preserving colors, styles, URLs, etc. */ - GHOSTTY_FORMATTER_FORMAT_VT, - - /** HTML with inline styles. */ - GHOSTTY_FORMATTER_FORMAT_HTML, - GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, -} GhosttyFormatterFormat; - /** * Extra screen state to include in styled output. * diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 52f1e09c2..142877a97 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -10,8 +10,10 @@ #include #include #include +#include #include #include +#include #ifdef __cplusplus extern "C" { @@ -161,6 +163,46 @@ typedef struct { bool semantic_prompt_boundary; } GhosttyTerminalSelectLineOptions; +/** + * Options for one-shot formatting of a terminal selection. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * If selection is NULL, the terminal's current active selection is used. + * If selection is non-NULL, that caller-provided snapshot selection is used. + * + * The selection is formatted from the terminal's active screen using the same + * formatting semantics as GhosttyFormatter. For copy/clipboard behavior + * matching Ghostty's Screen.selectionString(), use plain output with unwrap + * and trim both set to true. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectionFormatOptions). */ + size_t size; + + /** Output format to emit. */ + GhosttyFormatterFormat emit; + + /** Whether to unwrap soft-wrapped lines. */ + bool unwrap; + + /** Whether to trim trailing whitespace on non-blank lines. */ + bool trim; + + /** + * Optional selection to format. + * + * If NULL, the terminal's current active selection is used. If the terminal + * has no active selection, formatting returns GHOSTTY_NO_VALUE. + * + * If non-NULL, the pointed-to selection must be a valid snapshot selection + * for this terminal and must obey GhosttySelection lifetime rules. + */ + const GhosttySelection *selection; +} GhosttyTerminalSelectionFormatOptions; + /** * Ordering of a selection's endpoints in terminal coordinates. * @@ -355,6 +397,74 @@ GHOSTTY_API GhosttyResult ghostty_terminal_select_output( GhosttyGridRef ref, GhosttySelection* out_selection); +/** + * Format a terminal selection into a caller-provided buffer. + * + * This is a one-shot convenience API for formatting either the terminal's + * active selection or a caller-provided GhosttySelection without explicitly + * creating a GhosttyFormatter. + * + * Pass NULL for buf to query the required output size. In that case, + * out_written receives the required size and the function returns + * GHOSTTY_OUT_OF_SPACE. + * + * If buf is too small, the function returns GHOSTTY_OUT_OF_SPACE and writes + * the required size to out_written. The caller can then retry with a larger + * buffer. + * + * If options.selection is NULL and the terminal has no active selection, the + * function returns GHOSTTY_NO_VALUE. + * + * @param terminal The terminal to read from (must not be NULL) + * @param options Selection formatting options + * @param buf Output buffer, or NULL to query required size + * @param buf_len Length of buf in bytes + * @param out_written Number of bytes written, or required size on + * GHOSTTY_OUT_OF_SPACE (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_buf( + GhosttyTerminal terminal, + GhosttyTerminalSelectionFormatOptions options, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Format a terminal selection into an allocated buffer. + * + * This is a one-shot convenience API for formatting either the terminal's + * active selection or a caller-provided GhosttySelection without explicitly + * creating a GhosttyFormatter. + * + * The returned buffer is allocated using allocator, or the default allocator + * if NULL is passed. The caller owns the returned buffer and must free it with + * ghostty_free(), passing the same allocator and returned length. + * + * The returned bytes are not NUL-terminated. This supports plain text, VT, and + * HTML uniformly as byte output. + * + * If options.selection is NULL and the terminal has no active selection, the + * function returns GHOSTTY_NO_VALUE and leaves out_ptr as NULL and out_len as 0. + * + * @param terminal The terminal to read from (must not be NULL) + * @param allocator Allocator used for the returned buffer, or NULL for the default allocator + * @param options Selection formatting options + * @param out_ptr Receives the allocated output buffer (must not be NULL) + * @param out_len Receives the output length in bytes (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_alloc( + GhosttyTerminal terminal, + const GhosttyAllocator* allocator, + GhosttyTerminalSelectionFormatOptions options, + uint8_t** out_ptr, + size_t* out_len); + /** * Adjust a selection snapshot using terminal selection semantics. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index e8e976207..0e35124c6 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -194,6 +194,23 @@ typedef struct GhosttyOscCommandImpl* GhosttyOscCommand; /* ---- Common value types ---- */ +/** + * Terminal content output format. + * + * @ingroup formatter + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Plain text (no escape sequences). */ + GHOSTTY_FORMATTER_FORMAT_PLAIN, + + /** VT sequences preserving colors, styles, URLs, etc. */ + GHOSTTY_FORMATTER_FORMAT_VT, + + /** HTML with inline styles. */ + GHOSTTY_FORMATTER_FORMAT_HTML, + GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyFormatterFormat; + /** * A borrowed byte string (pointer + length). * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 291bf37f6..71b709135 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -209,6 +209,8 @@ comptime { @export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" }); @export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" }); @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); + @export(&c.terminal_selection_format_buf, .{ .name = "ghostty_terminal_selection_format_buf" }); + @export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" }); @export(&c.render_state_new, .{ .name = "ghostty_render_state_new" }); @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); @export(&c.render_state_get, .{ .name = "ghostty_render_state_get" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 3e776a0e4..1d78f06bb 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -175,6 +175,8 @@ pub const terminal_select_word_between = selection.word_between; pub const terminal_select_line = selection.line; pub const terminal_select_all = selection.all; pub const terminal_select_output = selection.output; +pub const terminal_selection_format_buf = selection.format_buf; +pub const terminal_selection_format_alloc = selection.format_alloc; pub const terminal_selection_adjust = selection.adjust; pub const terminal_selection_order = selection.order; pub const terminal_selection_ordered = selection.ordered; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 6bd8a9bb3..cb574ecc2 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -1,6 +1,8 @@ const std = @import("std"); const testing = std.testing; const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const formatterpkg = @import("../formatter.zig"); const grid_ref = @import("grid_ref.zig"); const point = @import("../point.zig"); const selection_codepoints = @import("../selection_codepoints.zig"); @@ -12,6 +14,7 @@ const log = std.log.scoped(.selection_c); pub const Adjustment = Selection.Adjustment; pub const Order = Selection.Order; +pub const Format = formatterpkg.Format; /// C: GhosttySelection pub const CSelection = extern struct { @@ -61,6 +64,15 @@ pub const SelectLineOptions = extern struct { semantic_prompt_boundary: bool = false, }; +/// C: GhosttyTerminalSelectionFormatOptions +pub const FormatOptions = extern struct { + size: usize = @sizeOf(FormatOptions), + emit: Format, + unwrap: bool, + trim: bool, + selection: ?*const CSelection = null, +}; + pub fn word( terminal: terminal_c.Terminal, options: ?*const SelectWordOptions, @@ -163,6 +175,101 @@ pub fn output( return .success; } +pub fn format_buf( + terminal: terminal_c.Terminal, + opts: FormatOptions, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + + if (out_ == null) { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + formatSelection(t, opts, &discarding.writer) catch |err| return switch (err) { + error.InvalidValue => .invalid_value, + error.NoValue => .no_value, + error.WriteFailed => unreachable, + }; + out_written.* = @intCast(discarding.count); + return .out_of_space; + } + + var writer: std.Io.Writer = .fixed(out_.?[0..out_len]); + formatSelection(t, opts, &writer) catch |err| switch (err) { + error.InvalidValue => return .invalid_value, + error.NoValue => return .no_value, + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + formatSelection(t, opts, &discarding.writer) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +pub fn format_alloc( + terminal: terminal_c.Terminal, + alloc_: ?*const CAllocator, + opts: FormatOptions, + out_ptr: *?[*]u8, + out_len: *usize, +) callconv(lib.calling_conv) Result { + out_ptr.* = null; + out_len.* = 0; + + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const alloc = lib.alloc.default(alloc_); + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + formatSelection(t, opts, &aw.writer) catch |err| return switch (err) { + error.InvalidValue => .invalid_value, + error.NoValue => .no_value, + error.WriteFailed => .out_of_memory, + }; + + const buf = aw.toOwnedSlice() catch return .out_of_memory; + out_ptr.* = buf.ptr; + out_len.* = buf.len; + return .success; +} + +fn formatSelection( + t: *terminal_c.ZigTerminal, + opts: FormatOptions, + writer: *std.Io.Writer, +) error{ InvalidValue, NoValue, WriteFailed }!void { + var formatter = selectionFormatter(t, opts) catch |err| return err; + try formatter.format(writer); +} + +fn selectionFormatter( + t: *terminal_c.ZigTerminal, + opts: FormatOptions, +) error{ InvalidValue, NoValue }!formatterpkg.TerminalFormatter { + if (opts.size < @sizeOf(FormatOptions)) return error.InvalidValue; + _ = std.meta.intToEnum(Format, @intFromEnum(opts.emit)) catch + return error.InvalidValue; + + const sel = if (opts.selection) |sel| + sel.toZig() orelse return error.InvalidValue + else + t.screens.active.selection orelse return error.NoValue; + + var formatter: formatterpkg.TerminalFormatter = .init(t, .{ + .emit = opts.emit, + .unwrap = opts.unwrap, + .trim = opts.trim, + }); + formatter.content = .{ .selection = sel }; + return formatter; +} + /// Return the borrowed C array of `uint32_t` codepoints as a `[]const u21`. /// /// `NULL + len 0` returns null, which callers treat as “use the API default @@ -284,3 +391,141 @@ pub fn equal( out.* = sel_a.eql(sel_b); return .success; } + +test "selection_format_alloc uses active selection" { + 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 = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello World", 11); + + var start_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 6, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 10, .y = 0 } }, + }, &end_ref)); + + const sel: CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + try testing.expectEqual(Result.success, terminal_c.set(t, .selection, @ptrCast(&sel))); + + const opts: FormatOptions = .{ + .emit = .plain, + .unwrap = true, + .trim = true, + }; + + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, format_buf( + t, + opts, + null, + 0, + &required, + )); + try testing.expectEqual(@as(usize, 5), required); + + var out_ptr: ?[*]u8 = null; + var out_len: usize = 0; + try testing.expectEqual(Result.success, format_alloc( + t, + &lib.alloc.test_allocator, + opts, + &out_ptr, + &out_len, + )); + const ptr = out_ptr orelse return error.TestExpectedEqual; + defer lib.alloc.default(&lib.alloc.test_allocator).free(ptr[0..out_len]); + + try testing.expectEqualStrings("World", ptr[0..out_len]); +} + +test "selection_format_buf uses provided selection" { + 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 = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello World", 11); + + var start_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 4, .y = 0 } }, + }, &end_ref)); + + const sel: CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + const opts: FormatOptions = .{ + .emit = .plain, + .unwrap = true, + .trim = true, + .selection = &sel, + }; + + var small: [2]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.out_of_space, format_buf( + t, + opts, + &small, + small.len, + &written, + )); + try testing.expectEqual(@as(usize, 5), written); + + var buf: [32]u8 = undefined; + try testing.expectEqual(Result.success, format_buf( + t, + opts, + &buf, + buf.len, + &written, + )); + try testing.expectEqualStrings("Hello", buf[0..written]); +} + +test "selection_format_alloc returns no_value without active selection" { + 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 = 10_000 }, + )); + defer terminal_c.free(t); + + var out_ptr: ?[*]u8 = @ptrFromInt(1); + var out_len: usize = 123; + try testing.expectEqual(Result.no_value, format_alloc( + t, + &lib.alloc.test_allocator, + .{ .emit = .plain, .unwrap = true, .trim = true }, + &out_ptr, + &out_len, + )); + try testing.expect(out_ptr == null); + try testing.expectEqual(@as(usize, 0), out_len); +}