aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/renderer/link.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/renderer/link.zig
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/renderer/link.zig')
-rw-r--r--vendor/libghostty-vt/src/renderer/link.zig322
1 files changed, 322 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/renderer/link.zig b/vendor/libghostty-vt/src/renderer/link.zig
new file mode 100644
index 00000000..c5de6157
--- /dev/null
+++ b/vendor/libghostty-vt/src/renderer/link.zig
@@ -0,0 +1,322 @@
+const std = @import("std");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
+const oni = @import("oniguruma");
+const inputpkg = @import("../input.zig");
+const terminal = @import("../terminal/main.zig");
+const point = terminal.point;
+const Screen = terminal.Screen;
+const Terminal = terminal.Terminal;
+
+const log = std.log.scoped(.renderer_link);
+
+/// The link configuration needed for renderers.
+pub const Link = struct {
+ /// The regular expression to match the link against.
+ regex: oni.Regex,
+
+ /// The situations in which the link should be highlighted.
+ highlight: inputpkg.Link.Highlight,
+
+ pub fn deinit(self: *Link) void {
+ self.regex.deinit();
+ }
+};
+
+/// A set of links. This provides a higher level API for renderers
+/// to match against a viewport and determine if cells are part of
+/// a link.
+pub const Set = struct {
+ links: []Link,
+
+ /// Returns the slice of links from the configuration.
+ pub fn fromConfig(
+ alloc: Allocator,
+ config: []const inputpkg.Link,
+ ) !Set {
+ var links: std.ArrayList(Link) = .empty;
+ defer links.deinit(alloc);
+
+ for (config) |link| {
+ var regex = try link.oniRegex();
+ errdefer regex.deinit();
+ try links.append(alloc, .{
+ .regex = regex,
+ .highlight = link.highlight,
+ });
+ }
+
+ return .{ .links = try links.toOwnedSlice(alloc) };
+ }
+
+ pub fn deinit(self: *Set, alloc: Allocator) void {
+ for (self.links) |*link| link.deinit();
+ alloc.free(self.links);
+ }
+
+ /// Fills matches with the matches from regex link matches.
+ pub fn renderCellMap(
+ self: *const Set,
+ alloc: Allocator,
+ result: *terminal.RenderState.CellSet,
+ render_state: *const terminal.RenderState,
+ mouse_viewport: ?point.Coordinate,
+ mouse_mods: inputpkg.Mods,
+ ) !void {
+ // Fast path, not very likely since we have default links.
+ if (self.links.len == 0) return;
+
+ // Convert our render state to a string + byte map.
+ var builder: std.Io.Writer.Allocating = .init(alloc);
+ defer builder.deinit();
+ var map: terminal.RenderState.StringMap = .empty;
+ defer map.deinit(alloc);
+ try render_state.string(&builder.writer, .{
+ .alloc = alloc,
+ .map = &map,
+ });
+
+ const str = builder.writer.buffered();
+
+ // Go through each link and see if we have any matches.
+ for (self.links) |*link| {
+ // Determine if our highlight conditions are met. We use a
+ // switch here instead of an if so that we can get a compile
+ // error if any other conditions are added.
+ switch (link.highlight) {
+ .always => {},
+ .always_mods => |v| if (!mouse_mods.equal(v)) continue,
+
+ // We check the hover points later.
+ .hover => if (mouse_viewport == null) continue,
+ .hover_mods => |v| {
+ if (mouse_viewport == null) continue;
+ if (!mouse_mods.equal(v)) continue;
+ },
+ }
+
+ var offset: usize = 0;
+ while (offset < str.len) {
+ var region = link.regex.search(
+ str[offset..],
+ .{},
+ ) catch |err| switch (err) {
+ error.Mismatch => break,
+ else => return err,
+ };
+ defer region.deinit();
+
+ // We have a match!
+ const offset_start: usize = @intCast(region.starts()[0]);
+ const offset_end: usize = @intCast(region.ends()[0]);
+ const start = offset + offset_start;
+ const end = offset + offset_end;
+
+ // Increment our offset by the number of bytes in the match.
+ // We defer this so that we can return the match before
+ // modifying the offset.
+ defer offset = end;
+
+ switch (link.highlight) {
+ .always, .always_mods => {},
+ .hover, .hover_mods => if (mouse_viewport) |vp| {
+ for (map.items[start..end]) |pt| {
+ if (pt.eql(vp)) break;
+ } else continue;
+ } else continue,
+ }
+
+ // Record the match
+ for (map.items[start..end]) |pt| {
+ try result.put(alloc, pt, {});
+ }
+ }
+ }
+ }
+};
+
+test "renderCellMap" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var t: terminal.Terminal = try .init(alloc, .{
+ .cols = 5,
+ .rows = 3,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ const str = "1ABCD2EFGH\r\n3IJKL";
+ s.nextSlice(str);
+
+ var state: terminal.RenderState = .empty;
+ defer state.deinit(alloc);
+ try state.update(alloc, &t);
+
+ // Get a set
+ var set = try Set.fromConfig(alloc, &.{
+ .{
+ .regex = "AB",
+ .action = .{ .open = {} },
+ .highlight = .{ .always = {} },
+ },
+
+ .{
+ .regex = "EF",
+ .action = .{ .open = {} },
+ .highlight = .{ .always = {} },
+ },
+ });
+ defer set.deinit(alloc);
+
+ // Get our matches
+ var result: terminal.RenderState.CellSet = .empty;
+ defer result.deinit(alloc);
+ try set.renderCellMap(
+ alloc,
+ &result,
+ &state,
+ null,
+ .{},
+ );
+ try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
+ try testing.expect(result.contains(.{ .x = 1, .y = 0 }));
+ try testing.expect(result.contains(.{ .x = 2, .y = 0 }));
+ try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
+ try testing.expect(result.contains(.{ .x = 1, .y = 1 }));
+ try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
+}
+
+test "renderCellMap hover links" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var t: terminal.Terminal = try .init(alloc, .{
+ .cols = 5,
+ .rows = 3,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ const str = "1ABCD2EFGH\r\n3IJKL";
+ s.nextSlice(str);
+
+ var state: terminal.RenderState = .empty;
+ defer state.deinit(alloc);
+ try state.update(alloc, &t);
+
+ // Get a set
+ var set = try Set.fromConfig(alloc, &.{
+ .{
+ .regex = "AB",
+ .action = .{ .open = {} },
+ .highlight = .{ .hover = {} },
+ },
+
+ .{
+ .regex = "EF",
+ .action = .{ .open = {} },
+ .highlight = .{ .always = {} },
+ },
+ });
+ defer set.deinit(alloc);
+
+ // Not hovering over the first link
+ {
+ var result: terminal.RenderState.CellSet = .empty;
+ defer result.deinit(alloc);
+ try set.renderCellMap(
+ alloc,
+ &result,
+ &state,
+ null,
+ .{},
+ );
+
+ // Test our matches
+ try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
+ try testing.expect(!result.contains(.{ .x = 1, .y = 0 }));
+ try testing.expect(!result.contains(.{ .x = 2, .y = 0 }));
+ try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
+ try testing.expect(result.contains(.{ .x = 1, .y = 1 }));
+ try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
+ }
+
+ // Hovering over the first link
+ {
+ var result: terminal.RenderState.CellSet = .empty;
+ defer result.deinit(alloc);
+ try set.renderCellMap(
+ alloc,
+ &result,
+ &state,
+ .{ .x = 1, .y = 0 },
+ .{},
+ );
+
+ // Test our matches
+ try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
+ try testing.expect(result.contains(.{ .x = 1, .y = 0 }));
+ try testing.expect(result.contains(.{ .x = 2, .y = 0 }));
+ try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
+ try testing.expect(result.contains(.{ .x = 1, .y = 1 }));
+ try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
+ }
+}
+
+test "renderCellMap mods no match" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var t: terminal.Terminal = try .init(alloc, .{
+ .cols = 5,
+ .rows = 3,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ const str = "1ABCD2EFGH\r\n3IJKL";
+ s.nextSlice(str);
+
+ var state: terminal.RenderState = .empty;
+ defer state.deinit(alloc);
+ try state.update(alloc, &t);
+
+ // Get a set
+ var set = try Set.fromConfig(alloc, &.{
+ .{
+ .regex = "AB",
+ .action = .{ .open = {} },
+ .highlight = .{ .always = {} },
+ },
+
+ .{
+ .regex = "EF",
+ .action = .{ .open = {} },
+ .highlight = .{ .always_mods = .{ .ctrl = true } },
+ },
+ });
+ defer set.deinit(alloc);
+
+ // Get our matches
+ var result: terminal.RenderState.CellSet = .empty;
+ defer result.deinit(alloc);
+ try set.renderCellMap(
+ alloc,
+ &result,
+ &state,
+ null,
+ .{},
+ );
+
+ // Test our matches
+ try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
+ try testing.expect(result.contains(.{ .x = 1, .y = 0 }));
+ try testing.expect(result.contains(.{ .x = 2, .y = 0 }));
+ try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
+ try testing.expect(!result.contains(.{ .x = 1, .y = 1 }));
+ try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
+}