From eb777b8036d8c457ee181eab136858d1ca86aa88 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 13:51:43 -0700 Subject: [PATCH] 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) },