From fa26e9a384e609388244d200a9bce9a479552dbe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 15:29:01 -1000 Subject: [PATCH] terminal: OSC8 hyperlinks in render state --- src/renderer/generic.zig | 47 +++++++++++++++++++----- src/terminal/render.zig | 79 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 42bcb8d1f..02f3a7357 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -5,6 +5,7 @@ const wuffs = @import("wuffs"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); +const inputpkg = @import("../input.zig"); const os = @import("../os/main.zig"); const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); @@ -1068,6 +1069,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Data we extract out of the critical area. const Critical = struct { + osc8_links: terminal.RenderState.CellSet, preedit: ?renderer.State.Preedit, cursor_style: ?renderer.CursorStyle, scrollbar: terminal.Scrollbar, @@ -1131,7 +1133,27 @@ pub fn Renderer(comptime GraphicsAPI: type) type { try self.prepKittyGraphics(state.terminal); } + // Get our OSC8 links we're hovering if we have a mouse. + // This requires terminal state because of URLs. + const osc8_links: terminal.RenderState.CellSet = osc8: { + // If our mouse isn't hovering, we have no links. + const vp = state.mouse.point orelse break :osc8 .empty; + + // If the right mods aren't pressed, then we can't match. + if (!state.mouse.mods.equal(inputpkg.ctrlOrSuper(.{}))) + break :osc8 .empty; + + break :osc8 self.terminal_state.linkCells( + arena_alloc, + vp, + ) catch |err| { + log.warn("error searching for OSC8 links err={}", .{err}); + break :osc8 .empty; + }; + }; + break :critical .{ + .osc8_links = osc8_links, .preedit = preedit, .cursor_style = cursor_style, .scrollbar = scrollbar, @@ -1142,6 +1164,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { try self.rebuildCells( critical.preedit, critical.cursor_style, + &critical.osc8_links, ); // Notify our shaper we're done for the frame. For some shapers, @@ -2225,6 +2248,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, + osc8_links: *const terminal.RenderState.CellSet, ) !void { const state: *terminal.RenderState = &self.terminal_state; defer state.redraw = false; @@ -2619,18 +2643,23 @@ pub fn Renderer(comptime GraphicsAPI: type) type { continue; } - // TODO: renderstate // Give links a single underline, unless they already have // an underline, in which case use a double underline to // distinguish them. - // const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) - // if (style.flags.underline == .single) - // .double - // else - // .single - // else - // style.flags.underline; - const underline = style.flags.underline; + const underline: terminal.Attribute.Underline = underline: { + // TODO: renderstate regex links + + if (osc8_links.contains(.{ + .x = @intCast(x), + .y = @intCast(y), + })) { + break :underline if (style.flags.underline == .single) + .double + else + .single; + } + break :underline style.flags.underline; + }; // We draw underlines first so that they layer underneath text. // This improves readability when a colored underline is used diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 395009ec9..381fbf12f 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -17,6 +17,7 @@ const Terminal = @import("Terminal.zig"); // - tests for cursor state // - tests for dirty state // - tests for colors +// - tests for linkCells // Developer note: this is in src/terminal and not src/renderer because // the goal is that this remains generic to multiple renderers. This can @@ -514,6 +515,84 @@ pub const RenderState = struct { t.flags.dirty = .{}; s.dirty = .{}; } + + /// A set of coordinates representing cells. + pub const CellSet = std.AutoArrayHashMapUnmanaged(point.Coordinate, void); + + /// Returns a map of the cells that match to an OSC8 hyperlink over the + /// given point in the render state. + /// + /// IMPORTANT: The terminal must not have updated since the last call to + /// `update`. If there is any chance the terminal has updated, the caller + /// must first call `update` again to refresh the render state. + /// + /// For example, you may want to hold a lock for the duration of the + /// update and hyperlink lookup to ensure no updates happen in between. + pub fn linkCells( + self: *const RenderState, + alloc: Allocator, + viewport_point: point.Coordinate, + ) Allocator.Error!CellSet { + var result: CellSet = .empty; + errdefer result.deinit(alloc); + + const row_slice = self.row_data.slice(); + const row_pins = row_slice.items(.pin); + const row_cells = row_slice.items(.cells); + + // Grab our link ID + const link_page: *page.Page = &row_pins[viewport_point.y].node.data; + const link = link: { + const rac = link_page.getRowAndCell( + viewport_point.x, + viewport_point.y, + ); + + // The likely scenario is that our mouse isn't even over a link. + if (!rac.cell.hyperlink) { + @branchHint(.likely); + return result; + } + + const link_id = link_page.lookupHyperlink(rac.cell) orelse + return result; + break :link link_page.hyperlink_set.get( + link_page.memory, + link_id, + ); + }; + + for ( + 0.., + row_pins, + row_cells, + ) |y, pin, cells| { + for (0.., cells.items(.raw)) |x, cell| { + if (!cell.hyperlink) continue; + + const other_page: *page.Page = &pin.node.data; + const other = link: { + const rac = other_page.getRowAndCell(x, y); + const link_id = other_page.lookupHyperlink(rac.cell) orelse continue; + break :link other_page.hyperlink_set.get( + other_page.memory, + link_id, + ); + }; + + if (link.eql( + link_page.memory, + other, + other_page.memory, + )) try result.put(alloc, .{ + .y = @intCast(y), + .x = @intCast(x), + }, {}); + } + } + + return result; + } }; test "styled" {