terminal: OSC8 hyperlinks in render state

pull/9662/head
Mitchell Hashimoto 2025-11-19 15:29:01 -10:00
parent 81142265aa
commit fa26e9a384
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
2 changed files with 117 additions and 9 deletions

View File

@ -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

View File

@ -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" {