diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-13 23:05:30 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-13 23:05:30 +0300 |
| commit | f8e59221cbe5117f8f691a36affa374f5a986a9f (patch) | |
| tree | 3b2792683eff251b061fbbad7343ecffafe91aab /vendor | |
| parent | 59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff) | |
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'vendor')
| -rw-r--r-- | vendor/libghostty-vt.patches.md | 43 | ||||
| -rw-r--r-- | vendor/libghostty-vt/include/ghostty/vt/selection.h | 15 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/lib_vt.zig | 1 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/Screen.zig | 105 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/main.zig | 1 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/selection.zig | 27 | ||||
| -rw-r--r-- | vendor/patches/libghostty-vt/0005-bounded-word-selection.patch | 204 |
7 files changed, 396 insertions, 0 deletions
diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index 40645acc..10596292 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -89,3 +89,46 @@ LIBGHOSTTY_VT_WINDOWS_LIBC=/path/to/windows-libc.txt just windows-lint LIBGHOSTTY_VT_SIMD=false just test-one ghostty just maintenance-test ``` + +## 0005 bounded word selection for wrapped link activation + +status: active + +patch: `vendor/patches/libghostty-vt/0005-bounded-word-selection.patch` + +herdr issue: https://github.com/herdrdev/herdr/issues/1282 + +upstream discussion: not opened + +upstream pr: not opened; related merged PR https://github.com/ghostty-org/ghostty/pull/10132 +implements URL selection in the application layer, not the libghostty C API. + +vendored base: `44f2a44df7e8c4a0c6df3f7d872ef3d7ead88e51` + +local files: + +- `vendor/libghostty-vt/include/ghostty/vt/selection.h` +- `vendor/libghostty-vt/src/lib_vt.zig` +- `vendor/libghostty-vt/src/terminal/Screen.zig` +- `vendor/libghostty-vt/src/terminal/c/main.zig` +- `vendor/libghostty-vt/src/terminal/c/selection.zig` + +reason: Ctrl+click must resolve a wrapped token beyond the visible viewport +without scanning an arbitrarily long logical line. The new, opt-in API shares +one cell-inspection budget across both directions and returns no selection on +exhaustion, never a truncated link. Its scan skips wide-character spacer cells. +The existing word-selection functions and option layouts remain unchanged; +only Herdr's link activation uses the new function. + +remove when: upstream provides an equivalent bounded, wrap-aware selection API +that handles wide-character spacers, and Herdr passes the tests below using it +without this patch. + +verification: + +```sh +just test-one link_target +just test-one link_activation +just test-one ctrl_click +just check +``` diff --git a/vendor/libghostty-vt/include/ghostty/vt/selection.h b/vendor/libghostty-vt/include/ghostty/vt/selection.h index 0158965e..3e557ef0 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/selection.h +++ b/vendor/libghostty-vt/include/ghostty/vt/selection.h @@ -770,6 +770,21 @@ GHOSTTY_API GhosttyResult ghostty_terminal_select_word( GhosttySelection* out_selection); /** + * Derive a word selection with a shared forward/backward cell inspection limit. + * + * Like ghostty_terminal_select_word(), but returns GHOSTTY_NO_VALUE if + * max_cells is zero or the search exhausts that budget. No partial selection + * is returned. The existing options structure and unbounded API are unchanged. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_word_bounded( + GhosttyTerminal terminal, + const GhosttyTerminalSelectWordOptions* options, + size_t max_cells, + GhosttySelection* out_selection); + +/** * Derive the nearest word selection snapshot between two terminal grid refs. * * Starting at options->start, this searches toward options->end (inclusive) diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig index a0227aef..48536568 100644 --- a/vendor/libghostty-vt/src/lib_vt.zig +++ b/vendor/libghostty-vt/src/lib_vt.zig @@ -331,6 +331,7 @@ comptime { @export(&c.terminal_continuation_alloc, .{ .name = "ghostty_terminal_continuation_alloc" }); if (features.selection) { @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); + @export(&c.terminal_select_word_bounded, .{ .name = "ghostty_terminal_select_word_bounded" }); @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" }); diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig index 208e276a..d0d2b929 100644 --- a/vendor/libghostty-vt/src/terminal/Screen.zig +++ b/vendor/libghostty-vt/src/terminal/Screen.zig @@ -3350,6 +3350,111 @@ pub fn selectWord( return .init(start, end, false); } +/// Like selectWord, but returns null instead of a partial selection when the +/// shared forward/backward cell inspection budget is exhausted. +pub fn selectWordBounded( + self: *Screen, + pin: Pin, + boundary_codepoints: []const u21, + max_cells: usize, +) ?Selection { + _ = self; + if (max_cells == 0) return null; + var remaining = max_cells - 1; + + // If our cell is empty we can't select a word, because we can't select + // areas where the screen is not yet written. + const start_cell = pin.rowAndCell().cell; + 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.indexOfScalar( + u21, + boundary_codepoints, + start_cell.content.codepoint.data, + ) != null; + + // Go forwards to find our end boundary + const end: Pin = end: { + var it = pin.cellIterator(.right_down, null); + var prev = it.next().?; // Consume one, our start + while (it.next()) |p| { + if (remaining == 0) return null; + remaining -= 1; + const rac = p.rowAndCell(); + const cell = rac.cell; + + // Wide-cell spacers are part of the same displayed word. + if (cell.wide == .spacer_tail or cell.wide == .spacer_head) { + prev = p; + if (p.x == p.node.cols() - 1 and !rac.row.wrap) break :end p; + continue; + } + + // If we reached an empty cell its always a boundary + if (!cell.hasText()) break :end prev; + + // If we do not match our expected set, we hit a boundary + const this_boundary = std.mem.indexOfScalar( + u21, + boundary_codepoints, + cell.content.codepoint.data, + ) != null; + if (this_boundary != expect_boundary) break :end prev; + + // If we are going to the next row and it isn't wrapped, we + // return the previous. + if (p.x == p.node.cols() - 1 and !rac.row.wrap) { + break :end p; + } + + prev = p; + } + + break :end prev; + }; + + // Go backwards to find our start boundary + const start: Pin = start: { + var it = pin.cellIterator(.left_up, null); + var prev = it.next().?; // Consume one, our start + while (it.next()) |p| { + if (remaining == 0) return null; + remaining -= 1; + const rac = p.rowAndCell(); + const cell = rac.cell; + + // If we are going to the next row and it isn't wrapped, we + // return the previous. + if (p.x == p.node.cols() - 1 and !rac.row.wrap) { + break :start prev; + } + + if (cell.wide == .spacer_tail or cell.wide == .spacer_head) { + prev = p; + continue; + } + + // If we reached an empty cell its always a boundary + if (!cell.hasText()) break :start prev; + + // If we do not match our expected set, we hit a boundary + const this_boundary = std.mem.indexOfScalar( + u21, + boundary_codepoints, + cell.content.codepoint.data, + ) != null; + if (this_boundary != expect_boundary) break :start prev; + + prev = p; + } + + break :start prev; + }; + + return .init(start, end, false); +} + /// Select the command output under the given point. The limits of the output /// are determined by semantic prompt information provided by shell integration. /// A selection can span multiple physical lines if they are soft-wrapped. diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig index 45b4dc47..0ba1224a 100644 --- a/vendor/libghostty-vt/src/terminal/c/main.zig +++ b/vendor/libghostty-vt/src/terminal/c/main.zig @@ -196,6 +196,7 @@ pub const terminal_continuation_write = terminal.continuation_write; pub const terminal_continuation_buf = terminal.continuation_buf; pub const terminal_continuation_alloc = terminal.continuation_alloc; pub const terminal_select_word = selection.word; +pub const terminal_select_word_bounded = selection.word_bounded; pub const terminal_select_word_between = selection.word_between; pub const terminal_select_line = selection.line; pub const terminal_select_all = selection.all; diff --git a/vendor/libghostty-vt/src/terminal/c/selection.zig b/vendor/libghostty-vt/src/terminal/c/selection.zig index 2830149f..63873bed 100644 --- a/vendor/libghostty-vt/src/terminal/c/selection.zig +++ b/vendor/libghostty-vt/src/terminal/c/selection.zig @@ -110,6 +110,33 @@ pub fn word( return .success; } +pub fn word_bounded( + terminal: terminal_c.Terminal, + options: ?*const SelectWordOptions, + max_cells: usize, + 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.selectWordBounded( + pin, + boundary_codepoints orelse &selection_codepoints.default_word_boundaries, + max_cells, + ) orelse + return .no_value); + return .success; +} + pub fn word_between( terminal: terminal_c.Terminal, options: ?*const SelectWordBetweenOptions, diff --git a/vendor/patches/libghostty-vt/0005-bounded-word-selection.patch b/vendor/patches/libghostty-vt/0005-bounded-word-selection.patch new file mode 100644 index 00000000..614d962f --- /dev/null +++ b/vendor/patches/libghostty-vt/0005-bounded-word-selection.patch @@ -0,0 +1,204 @@ +diff --git a/vendor/libghostty-vt/include/ghostty/vt/selection.h b/vendor/libghostty-vt/include/ghostty/vt/selection.h +index 0158965e..3e557ef0 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/selection.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/selection.h +@@ -769,6 +769,21 @@ GHOSTTY_API GhosttyResult ghostty_terminal_select_word( + const GhosttyTerminalSelectWordOptions* options, + GhosttySelection* out_selection); + ++/** ++ * Derive a word selection with a shared forward/backward cell inspection limit. ++ * ++ * Like ghostty_terminal_select_word(), but returns GHOSTTY_NO_VALUE if ++ * max_cells is zero or the search exhausts that budget. No partial selection ++ * is returned. The existing options structure and unbounded API are unchanged. ++ * ++ * @ingroup selection ++ */ ++GHOSTTY_API GhosttyResult ghostty_terminal_select_word_bounded( ++ GhosttyTerminal terminal, ++ const GhosttyTerminalSelectWordOptions* options, ++ size_t max_cells, ++ GhosttySelection* out_selection); ++ + /** + * Derive the nearest word selection snapshot between two terminal grid refs. + * +diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig +index a0227aef..48536568 100644 +--- a/vendor/libghostty-vt/src/lib_vt.zig ++++ b/vendor/libghostty-vt/src/lib_vt.zig +@@ -331,6 +331,7 @@ comptime { + @export(&c.terminal_continuation_alloc, .{ .name = "ghostty_terminal_continuation_alloc" }); + if (features.selection) { + @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); ++ @export(&c.terminal_select_word_bounded, .{ .name = "ghostty_terminal_select_word_bounded" }); + @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" }); +diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig +index 208e276a..d0d2b929 100644 +--- a/vendor/libghostty-vt/src/terminal/Screen.zig ++++ b/vendor/libghostty-vt/src/terminal/Screen.zig +@@ -3350,6 +3350,111 @@ pub fn selectWord( + return .init(start, end, false); + } + ++/// Like selectWord, but returns null instead of a partial selection when the ++/// shared forward/backward cell inspection budget is exhausted. ++pub fn selectWordBounded( ++ self: *Screen, ++ pin: Pin, ++ boundary_codepoints: []const u21, ++ max_cells: usize, ++) ?Selection { ++ _ = self; ++ if (max_cells == 0) return null; ++ var remaining = max_cells - 1; ++ ++ // If our cell is empty we can't select a word, because we can't select ++ // areas where the screen is not yet written. ++ const start_cell = pin.rowAndCell().cell; ++ 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.indexOfScalar( ++ u21, ++ boundary_codepoints, ++ start_cell.content.codepoint.data, ++ ) != null; ++ ++ // Go forwards to find our end boundary ++ const end: Pin = end: { ++ var it = pin.cellIterator(.right_down, null); ++ var prev = it.next().?; // Consume one, our start ++ while (it.next()) |p| { ++ if (remaining == 0) return null; ++ remaining -= 1; ++ const rac = p.rowAndCell(); ++ const cell = rac.cell; ++ ++ // Wide-cell spacers are part of the same displayed word. ++ if (cell.wide == .spacer_tail or cell.wide == .spacer_head) { ++ prev = p; ++ if (p.x == p.node.cols() - 1 and !rac.row.wrap) break :end p; ++ continue; ++ } ++ ++ // If we reached an empty cell its always a boundary ++ if (!cell.hasText()) break :end prev; ++ ++ // If we do not match our expected set, we hit a boundary ++ const this_boundary = std.mem.indexOfScalar( ++ u21, ++ boundary_codepoints, ++ cell.content.codepoint.data, ++ ) != null; ++ if (this_boundary != expect_boundary) break :end prev; ++ ++ // If we are going to the next row and it isn't wrapped, we ++ // return the previous. ++ if (p.x == p.node.cols() - 1 and !rac.row.wrap) { ++ break :end p; ++ } ++ ++ prev = p; ++ } ++ ++ break :end prev; ++ }; ++ ++ // Go backwards to find our start boundary ++ const start: Pin = start: { ++ var it = pin.cellIterator(.left_up, null); ++ var prev = it.next().?; // Consume one, our start ++ while (it.next()) |p| { ++ if (remaining == 0) return null; ++ remaining -= 1; ++ const rac = p.rowAndCell(); ++ const cell = rac.cell; ++ ++ // If we are going to the next row and it isn't wrapped, we ++ // return the previous. ++ if (p.x == p.node.cols() - 1 and !rac.row.wrap) { ++ break :start prev; ++ } ++ ++ if (cell.wide == .spacer_tail or cell.wide == .spacer_head) { ++ prev = p; ++ continue; ++ } ++ ++ // If we reached an empty cell its always a boundary ++ if (!cell.hasText()) break :start prev; ++ ++ // If we do not match our expected set, we hit a boundary ++ const this_boundary = std.mem.indexOfScalar( ++ u21, ++ boundary_codepoints, ++ cell.content.codepoint.data, ++ ) != null; ++ if (this_boundary != expect_boundary) break :start prev; ++ ++ prev = p; ++ } ++ ++ break :start prev; ++ }; ++ ++ return .init(start, end, false); ++} ++ + /// Select the command output under the given point. The limits of the output + /// are determined by semantic prompt information provided by shell integration. + /// A selection can span multiple physical lines if they are soft-wrapped. +diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig +index 45b4dc47..0ba1224a 100644 +--- a/vendor/libghostty-vt/src/terminal/c/main.zig ++++ b/vendor/libghostty-vt/src/terminal/c/main.zig +@@ -196,6 +196,7 @@ pub const terminal_continuation_write = terminal.continuation_write; + pub const terminal_continuation_buf = terminal.continuation_buf; + pub const terminal_continuation_alloc = terminal.continuation_alloc; + pub const terminal_select_word = selection.word; ++pub const terminal_select_word_bounded = selection.word_bounded; + pub const terminal_select_word_between = selection.word_between; + pub const terminal_select_line = selection.line; + pub const terminal_select_all = selection.all; +diff --git a/vendor/libghostty-vt/src/terminal/c/selection.zig b/vendor/libghostty-vt/src/terminal/c/selection.zig +index 2830149f..63873bed 100644 +--- a/vendor/libghostty-vt/src/terminal/c/selection.zig ++++ b/vendor/libghostty-vt/src/terminal/c/selection.zig +@@ -110,6 +110,33 @@ pub fn word( + return .success; + } + ++pub fn word_bounded( ++ terminal: terminal_c.Terminal, ++ options: ?*const SelectWordOptions, ++ max_cells: usize, ++ 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.selectWordBounded( ++ pin, ++ boundary_codepoints orelse &selection_codepoints.default_word_boundaries, ++ max_cells, ++ ) orelse ++ return .no_value); ++ return .success; ++} ++ + pub fn word_between( + terminal: terminal_c.Terminal, + options: ?*const SelectWordBetweenOptions, |
