aboutsummaryrefslogtreecommitdiff
path: root/vendor
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-13 23:05:30 +0300
committerGitHub <noreply@github.com>2026-09-13 23:05:30 +0300
commitf8e59221cbe5117f8f691a36affa374f5a986a9f (patch)
tree3b2792683eff251b061fbbad7343ecffafe91aab /vendor
parent59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff)
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'vendor')
-rw-r--r--vendor/libghostty-vt.patches.md43
-rw-r--r--vendor/libghostty-vt/include/ghostty/vt/selection.h15
-rw-r--r--vendor/libghostty-vt/src/lib_vt.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/Screen.zig105
-rw-r--r--vendor/libghostty-vt/src/terminal/c/main.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/c/selection.zig27
-rw-r--r--vendor/patches/libghostty-vt/0005-bounded-word-selection.patch204
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,