@@ -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 .
231231const 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 , "&" )) {
415- dst [di ] = '&' ;
416- di += 1 ;
417- si += 5 ;
418- } else if (std .mem .startsWith (u8 , rem , "<" )) {
419- dst [di ] = '<' ;
420- di += 1 ;
421- si += 4 ;
422- } else if (std .mem .startsWith (u8 , rem , ">" )) {
423- dst [di ] = '>' ;
424- di += 1 ;
425- si += 4 ;
426- } else if (std .mem .startsWith (u8 , rem , """ )) {
427- dst [di ] = '"' ;
428- di += 1 ;
429- si += 6 ;
430- } else if (std .mem .startsWith (u8 , rem , "'" )) {
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