Skip to content

Commit 23aa22a

Browse files
committed
Use grid_ref API for hyperlink detection instead of HTML formatter
Replace the roundabout HTML formatter approach (~200 lines) with direct cell queries using the new ghostty_grid_ref_hyperlink_uri() API. Instead of formatting the entire terminal screen to HTML and parsing it to find <a> tags, we now query hyperlink URIs directly from cells on rows flagged with GHOSTTY_ROW_DATA_HYPERLINK.
1 parent 9cd4b25 commit 23aa22a

2 files changed

Lines changed: 101 additions & 191 deletions

File tree

src/ghostty.zig

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,19 @@ pub const TitleChangedFn = c.GhosttyTerminalTitleChangedFn;
3535
pub const DeviceAttributesFn = c.GhosttyTerminalDeviceAttributesFn;
3636
pub const DeviceAttributes = c.GhosttyDeviceAttributes;
3737

38+
// Grid reference types
39+
pub const GridRef = c.GhosttyGridRef;
40+
pub const Point = c.GhosttyPoint;
41+
pub const PointTag = c.GhosttyPointTag;
42+
pub const PointCoordinate = c.GhosttyPointCoordinate;
43+
pub const PointValue = c.GhosttyPointValue;
44+
3845
// Result constants
3946
pub const SUCCESS = c.GHOSTTY_SUCCESS;
4047
pub const OUT_OF_MEMORY = c.GHOSTTY_OUT_OF_MEMORY;
4148
pub const INVALID_VALUE = c.GHOSTTY_INVALID_VALUE;
4249
pub const NO_VALUE = c.GHOSTTY_NO_VALUE;
50+
pub const OUT_OF_SPACE = c.GHOSTTY_OUT_OF_SPACE;
4351

4452
// Terminal option constants
4553
pub const OPT_USERDATA = c.GHOSTTY_TERMINAL_OPT_USERDATA;
@@ -111,9 +119,5 @@ pub const SCROLL_TOP: c_int = c.GHOSTTY_SCROLL_VIEWPORT_TOP;
111119
pub const SCROLL_BOTTOM: c_int = c.GHOSTTY_SCROLL_VIEWPORT_BOTTOM;
112120
pub const SCROLL_DELTA: c_int = c.GHOSTTY_SCROLL_VIEWPORT_DELTA;
113121

114-
// Formatter types and constants
115-
pub const Formatter = c.GhosttyFormatter;
116-
pub const FormatterTerminalOptions = c.GhosttyFormatterTerminalOptions;
117-
pub const FormatterTerminalExtra = c.GhosttyFormatterTerminalExtra;
118-
pub const FormatterScreenExtra = c.GhosttyFormatterScreenExtra;
119-
pub const FORMATTER_FORMAT_HTML: c_int = c.GHOSTTY_FORMATTER_FORMAT_HTML;
122+
// Point tag constants
123+
pub const POINT_TAG_VIEWPORT: c_int = c.GHOSTTY_POINT_TAG_VIEWPORT;

src/render.zig

Lines changed: 91 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ fn applyStyle(env: emacs.Env, start: i64, end: i64, style: CellStyle, default_fg
227227
env.putTextProperty(start_val, end_val, s.face, face);
228228
}
229229

230-
/// A hyperlink span detected from the HTML formatter output.
230+
/// A hyperlink span detected from the terminal grid.
231231
const HyperlinkSpan = struct {
232232
row: u16,
233233
col_start: u16,
@@ -242,207 +242,102 @@ const HyperlinkResult = struct {
242242
uri_used: usize,
243243
};
244244

245-
/// Scan the terminal for hyperlinks using the HTML formatter.
246-
/// Returns the number of hyperlink spans found.
247-
fn scanHyperlinks(
248-
term: *Terminal,
245+
/// Scan specific rows for hyperlinks using the grid_ref API.
246+
/// Queries each cell directly for its hyperlink URI, coalescing
247+
/// adjacent cells with the same URI into spans.
248+
fn scanHyperlinksFromGrid(
249+
terminal: gt.Terminal,
250+
cols: u16,
251+
hyperlink_rows: []const u16,
249252
spans: []HyperlinkSpan,
250253
uri_buf: []u8,
251254
) HyperlinkResult {
252-
// Create HTML formatter
253-
var opts = std.mem.zeroes(gt.FormatterTerminalOptions);
254-
opts.size = @sizeOf(gt.FormatterTerminalOptions);
255-
opts.emit = @intCast(gt.FORMATTER_FORMAT_HTML);
256-
257-
var formatter: gt.Formatter = undefined;
258-
if (gt.c.ghostty_formatter_terminal_new(null, &formatter, term.terminal, opts) != gt.SUCCESS) {
259-
return .{ .count = 0, .uri_used = 0 };
260-
}
261-
defer gt.c.ghostty_formatter_free(formatter);
262-
263-
// Format into a stack buffer; fall back to heap if too small
264-
var html_buf: [262144]u8 = undefined;
265-
var out_len: usize = 0;
266-
if (gt.c.ghostty_formatter_format_buf(formatter, &html_buf, html_buf.len, &out_len) == gt.SUCCESS) {
267-
return parseHtmlHyperlinks(html_buf[0..out_len], spans, uri_buf);
268-
}
269-
270-
const heap_buf = std.heap.page_allocator.alloc(u8, 1024 * 1024) catch {
271-
return .{ .count = 0, .uri_used = 0 };
272-
};
273-
defer std.heap.page_allocator.free(heap_buf);
274-
if (gt.c.ghostty_formatter_format_buf(formatter, heap_buf.ptr, heap_buf.len, &out_len) != gt.SUCCESS) {
275-
return .{ .count = 0, .uri_used = 0 };
276-
}
277-
return parseHtmlHyperlinks(heap_buf[0..out_len], spans, uri_buf);
278-
}
279-
280-
/// Parse HTML output to extract hyperlink spans and their URIs.
281-
fn parseHtmlHyperlinks(
282-
html: []const u8,
283-
spans: []HyperlinkSpan,
284-
uri_buf: []u8,
285-
) HyperlinkResult {
286-
var row: u16 = 0;
287-
var col: u16 = 0;
288255
var span_count: usize = 0;
289256
var uri_used: usize = 0;
290257

291-
var in_link = false;
292-
var link_start_col: u16 = 0;
293-
var link_start_row: u16 = 0;
294-
var link_uri_start: usize = 0;
295-
var link_uri_len: usize = 0;
296-
297-
const a_open = "<a href=\"";
298-
const a_close = "</a>";
299-
300-
var i: usize = 0;
301-
while (i < html.len) {
302-
if (html[i] == '<') {
303-
const remaining = html[i..];
304-
if (remaining.len >= a_open.len and std.mem.eql(u8, remaining[0..a_open.len], a_open)) {
305-
// <a href="URI"> — extract URI
306-
const uri_start_idx = i + a_open.len;
307-
var uri_end_idx = uri_start_idx;
308-
while (uri_end_idx < html.len and html[uri_end_idx] != '"') : (uri_end_idx += 1) {}
309-
310-
const raw_uri = html[uri_start_idx..uri_end_idx];
311-
// Un-escape HTML entities in the URI
312-
const decoded_len = htmlUnescapeInto(raw_uri, uri_buf[uri_used..]);
313-
if (decoded_len > 0) {
314-
link_uri_start = uri_used;
315-
link_uri_len = decoded_len;
316-
uri_used += decoded_len;
317-
}
258+
for (hyperlink_rows) |row| {
259+
var in_link = false;
260+
var link_start_col: u16 = 0;
261+
var link_uri_start: usize = 0;
262+
var link_uri_len: usize = 0;
263+
264+
for (0..cols) |col_idx| {
265+
const col: u16 = @intCast(col_idx);
318266

319-
// Close any previous open link
267+
// Build viewport point for this cell
268+
var point: gt.Point = undefined;
269+
point.tag = gt.c.GHOSTTY_POINT_TAG_VIEWPORT;
270+
point.value = .{ .coordinate = .{ .x = col, .y = @intCast(row) } };
271+
272+
// Resolve grid ref
273+
var grid_ref = std.mem.zeroes(gt.GridRef);
274+
grid_ref.size = @sizeOf(gt.GridRef);
275+
if (gt.c.ghostty_terminal_grid_ref(terminal, point, &grid_ref) != gt.SUCCESS) {
320276
if (in_link and span_count < spans.len and col > link_start_col) {
321-
spans[span_count] = .{
322-
.row = link_start_row,
323-
.col_start = link_start_col,
324-
.col_end = col,
325-
.uri_start = link_uri_start,
326-
.uri_len = link_uri_len,
327-
};
277+
spans[span_count] = .{ .row = row, .col_start = link_start_col, .col_end = col, .uri_start = link_uri_start, .uri_len = link_uri_len };
328278
span_count += 1;
329279
}
280+
in_link = false;
281+
continue;
282+
}
330283

331-
in_link = true;
332-
link_start_row = row;
333-
link_start_col = col;
284+
// Query hyperlink URI (stack buffer; heap fallback for long URIs)
285+
var uri_stack: [2048]u8 = undefined;
286+
var out_len: usize = 0;
287+
var result = gt.c.ghostty_grid_ref_hyperlink_uri(&grid_ref, &uri_stack, uri_stack.len, &out_len);
288+
var heap_uri: ?[]u8 = null;
289+
defer if (heap_uri) |buf| std.heap.c_allocator.free(buf);
290+
291+
if (result == gt.OUT_OF_SPACE and out_len > uri_stack.len) {
292+
if (std.heap.c_allocator.alloc(u8, out_len)) |buf| {
293+
heap_uri = buf;
294+
result = gt.c.ghostty_grid_ref_hyperlink_uri(&grid_ref, buf.ptr, buf.len, &out_len);
295+
} else |_| {}
296+
}
334297

335-
// Skip to end of tag
336-
while (i < html.len and html[i] != '>') : (i += 1) {}
337-
if (i < html.len) i += 1;
338-
continue;
339-
} else if (remaining.len >= a_close.len and std.mem.eql(u8, remaining[0..a_close.len], a_close)) {
340-
// </a> — end hyperlink
341-
if (in_link and span_count < spans.len) {
342-
const start_col = if (row == link_start_row) link_start_col else 0;
343-
if (col > start_col) {
344-
spans[span_count] = .{
345-
.row = row,
346-
.col_start = start_col,
347-
.col_end = col,
348-
.uri_start = link_uri_start,
349-
.uri_len = link_uri_len,
350-
};
298+
if (result == gt.SUCCESS and out_len > 0) {
299+
const uri = if (heap_uri) |buf| buf[0..out_len] else uri_stack[0..out_len];
300+
// Check if this extends the current span (same URI)
301+
if (in_link and link_uri_len == out_len and
302+
std.mem.eql(u8, uri_buf[link_uri_start..link_uri_start + link_uri_len], uri))
303+
{
304+
// Same URI — span continues
305+
} else {
306+
// Close previous span if any
307+
if (in_link and span_count < spans.len and col > link_start_col) {
308+
spans[span_count] = .{ .row = row, .col_start = link_start_col, .col_end = col, .uri_start = link_uri_start, .uri_len = link_uri_len };
351309
span_count += 1;
352310
}
311+
// Start new span, copy URI to shared buffer
312+
if (uri_used + out_len <= uri_buf.len) {
313+
@memcpy(uri_buf[uri_used .. uri_used + out_len], uri);
314+
link_uri_start = uri_used;
315+
link_uri_len = out_len;
316+
uri_used += out_len;
317+
link_start_col = col;
318+
in_link = true;
319+
} else {
320+
in_link = false;
321+
}
353322
}
354-
in_link = false;
355-
i += a_close.len;
356-
continue;
357323
} else {
358-
// Other tag — skip to closing >
359-
while (i < html.len and html[i] != '>') : (i += 1) {}
360-
if (i < html.len) i += 1;
361-
continue;
362-
}
363-
} else if (html[i] == '&') {
364-
// HTML entity — counts as 1 visible character
365-
while (i < html.len and html[i] != ';') : (i += 1) {}
366-
if (i < html.len) i += 1;
367-
col += 1;
368-
} else if (html[i] == '\n') {
369-
// Row boundary
370-
if (in_link) {
371-
const start_col = if (row == link_start_row) link_start_col else 0;
372-
if (col > start_col and span_count < spans.len) {
373-
spans[span_count] = .{
374-
.row = row,
375-
.col_start = start_col,
376-
.col_end = col,
377-
.uri_start = link_uri_start,
378-
.uri_len = link_uri_len,
379-
};
324+
// No hyperlink on this cell — close any open span
325+
if (in_link and span_count < spans.len and col > link_start_col) {
326+
spans[span_count] = .{ .row = row, .col_start = link_start_col, .col_end = col, .uri_start = link_uri_start, .uri_len = link_uri_len };
380327
span_count += 1;
381328
}
382-
// Link continues on next row
383-
link_start_row = row + 1;
384-
link_start_col = 0;
385-
}
386-
row += 1;
387-
col = 0;
388-
i += 1;
389-
} else {
390-
// Regular character — advance column, handle UTF-8
391-
if (html[i] & 0x80 == 0) {
392-
i += 1;
393-
} else if (html[i] & 0xE0 == 0xC0) {
394-
i += 2;
395-
} else if (html[i] & 0xF0 == 0xE0) {
396-
i += 3;
397-
} else {
398-
i += @min(4, html.len - i);
329+
in_link = false;
399330
}
400-
col += 1;
401331
}
402-
}
403332

404-
return .{ .count = span_count, .uri_used = uri_used };
405-
}
406-
407-
/// Decode HTML entities in src into dst. Returns number of bytes written.
408-
fn htmlUnescapeInto(src: []const u8, dst: []u8) usize {
409-
var di: usize = 0;
410-
var si: usize = 0;
411-
while (si < src.len and di < dst.len) {
412-
if (src[si] == '&') {
413-
const rem = src[si..];
414-
if (std.mem.startsWith(u8, rem, "&amp;")) {
415-
dst[di] = '&';
416-
di += 1;
417-
si += 5;
418-
} else if (std.mem.startsWith(u8, rem, "&lt;")) {
419-
dst[di] = '<';
420-
di += 1;
421-
si += 4;
422-
} else if (std.mem.startsWith(u8, rem, "&gt;")) {
423-
dst[di] = '>';
424-
di += 1;
425-
si += 4;
426-
} else if (std.mem.startsWith(u8, rem, "&quot;")) {
427-
dst[di] = '"';
428-
di += 1;
429-
si += 6;
430-
} else if (std.mem.startsWith(u8, rem, "&#39;")) {
431-
dst[di] = '\'';
432-
di += 1;
433-
si += 5;
434-
} else {
435-
dst[di] = src[si];
436-
di += 1;
437-
si += 1;
438-
}
439-
} else {
440-
dst[di] = src[si];
441-
di += 1;
442-
si += 1;
333+
// Close span at end of row
334+
if (in_link and span_count < spans.len and cols > link_start_col) {
335+
spans[span_count] = .{ .row = row, .col_start = link_start_col, .col_end = cols, .uri_start = link_uri_start, .uri_len = link_uri_len };
336+
span_count += 1;
443337
}
444338
}
445-
return di;
339+
340+
return .{ .count = span_count, .uri_used = uri_used };
446341
}
447342

448343
/// Apply hyperlink text properties to the Emacs buffer.
@@ -655,6 +550,8 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void {
655550
var dirty: c_int = gt.DIRTY_FALSE;
656551
_ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_DIRTY, @ptrCast(&dirty));
657552
var has_hyperlinks: bool = false;
553+
var hyperlink_rows: [256]u16 = undefined;
554+
var hyperlink_row_count: usize = 0;
658555
var has_wide_chars: bool = false;
659556

660557
if (dirty != gt.DIRTY_FALSE) {
@@ -712,12 +609,16 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void {
712609
}
713610

714611
// Check for hyperlinks (row-level flag, may have false positives)
715-
if (!has_hyperlinks) {
612+
{
716613
var raw_row: gt.c.GhosttyRow = undefined;
717614
if (gt.c.ghostty_render_state_row_get(term.row_iterator, gt.c.GHOSTTY_RENDER_STATE_ROW_DATA_RAW, @ptrCast(&raw_row)) == gt.SUCCESS) {
718615
var row_has_links: bool = false;
719616
_ = gt.c.ghostty_row_get(raw_row, gt.ROW_DATA_HYPERLINK, @ptrCast(&row_has_links));
720-
if (row_has_links) has_hyperlinks = true;
617+
if (row_has_links and hyperlink_row_count < hyperlink_rows.len) {
618+
hyperlink_rows[hyperlink_row_count] = @intCast(row_count);
619+
hyperlink_row_count += 1;
620+
has_hyperlinks = true;
621+
}
721622
}
722623
}
723624

@@ -795,13 +696,18 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void {
795696
}
796697

797698
// Scan for hyperlinks and apply text properties (before cursor positioning).
798-
// Only run the expensive HTML formatter when a viewport row actually has
799-
// a hyperlink — this avoids formatting the entire terminal (including
800-
// scrollback) on every redraw.
699+
// Uses the grid_ref API to query hyperlink URIs directly from cells,
700+
// only for rows flagged with GHOSTTY_ROW_DATA_HYPERLINK.
801701
if (dirty != gt.DIRTY_FALSE and has_hyperlinks) {
802702
var hl_spans: [128]HyperlinkSpan = undefined;
803703
var hl_uri_buf: [8192]u8 = undefined;
804-
const hl = scanHyperlinks(term, &hl_spans, &hl_uri_buf);
704+
const hl = scanHyperlinksFromGrid(
705+
term.terminal,
706+
term.cols,
707+
hyperlink_rows[0..hyperlink_row_count],
708+
&hl_spans,
709+
&hl_uri_buf,
710+
);
805711
if (hl.count > 0) {
806712
applyHyperlinks(env, &hl_spans, hl.count, &hl_uri_buf);
807713
}

0 commit comments

Comments
 (0)