supercode_frontend_tui/terminal/
hyperlinks.rs1use std::ops::Range;
13
14use ratatui::buffer::Buffer;
15use ratatui::buffer::CellDiffOption;
16use ratatui::layout::Rect;
17use ratatui::style::Color;
18use ratatui::style::Modifier;
19use ratatui::text::Line;
20use ratatui::text::Span;
21use ratatui::text::Text;
22use ratatui::widgets::Paragraph;
23use ratatui::widgets::Widget;
24use ratatui::widgets::Wrap;
25use unicode_width::UnicodeWidthChar;
26use unicode_width::UnicodeWidthStr;
27use url::Url;
28
29use crate::foundation::wrapping::adaptive_wrap_line;
30use crate::foundation::wrapping::RtOptions;
31
32fn line_to_static(line: &Line<'_>) -> Line<'static> {
33 Line {
34 style: line.style,
35 alignment: line.alignment,
36 spans: line
37 .spans
38 .iter()
39 .map(|span| Span::styled(span.content.to_string(), span.style))
40 .collect(),
41 }
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct TerminalHyperlink {
46 pub columns: Range<usize>,
47 pub destination: String,
48}
49
50#[derive(Clone, Debug, Default, Eq, PartialEq)]
51pub struct HyperlinkLine {
52 pub line: Line<'static>,
53 pub hyperlinks: Vec<TerminalHyperlink>,
54}
55
56impl HyperlinkLine {
57 pub fn new(line: Line<'static>) -> Self {
58 Self {
59 line,
60 hyperlinks: Vec::new(),
61 }
62 }
63
64 pub fn width(&self) -> usize {
65 self.line.width()
66 }
67
68 pub fn push_span(&mut self, span: Span<'static>, destination: Option<&str>) {
69 let start = self.width();
70 let end = start + span.content.width();
71 self.line.push_span(span);
72 if end > start {
73 if let Some(destination) = destination.and_then(web_destination) {
74 self.hyperlinks.push(TerminalHyperlink {
75 columns: start..end,
76 destination,
77 });
78 }
79 }
80 }
81
82 pub fn style(mut self, style: ratatui::style::Style) -> Self {
83 self.line = self.line.style(style);
84 self
85 }
86}
87
88impl From<Line<'static>> for HyperlinkLine {
89 fn from(line: Line<'static>) -> Self {
90 Self::new(line)
91 }
92}
93
94impl From<&'static str> for HyperlinkLine {
95 fn from(text: &'static str) -> Self {
96 Self::new(Line::from(text))
97 }
98}
99
100impl From<String> for HyperlinkLine {
101 fn from(text: String) -> Self {
102 Self::new(Line::from(text))
103 }
104}
105
106pub fn visible_lines(lines: Vec<HyperlinkLine>) -> Vec<Line<'static>> {
107 lines.into_iter().map(|line| line.line).collect()
108}
109
110pub fn plain_hyperlink_lines(lines: Vec<Line<'static>>) -> Vec<HyperlinkLine> {
111 lines.into_iter().map(HyperlinkLine::new).collect()
112}
113
114pub fn prefix_hyperlink_lines(
115 lines: Vec<HyperlinkLine>,
116 initial_prefix: Span<'static>,
117 subsequent_prefix: Span<'static>,
118) -> Vec<HyperlinkLine> {
119 lines
120 .into_iter()
121 .enumerate()
122 .map(|(index, mut line)| {
123 let prefix = if index == 0 {
124 initial_prefix.clone()
125 } else {
126 subsequent_prefix.clone()
127 };
128 let shift = prefix.content.width();
129 let mut spans = Vec::with_capacity(line.line.spans.len() + 1);
130 spans.push(prefix);
131 spans.extend(line.line.spans);
132 line.line = Line::from(spans).style(line.line.style);
133 for hyperlink in &mut line.hyperlinks {
134 hyperlink.columns = hyperlink.columns.start + shift..hyperlink.columns.end + shift;
135 }
136 line
137 })
138 .collect()
139}
140
141pub fn adaptive_wrap_hyperlink_lines(
142 lines: &[HyperlinkLine],
143 options: RtOptions<'static>,
144) -> Vec<HyperlinkLine> {
145 let mut out = Vec::new();
146 for (index, line) in lines.iter().enumerate() {
147 let options = if index == 0 {
148 options.clone()
149 } else {
150 options
151 .clone()
152 .initial_indent(options.subsequent_indent.clone())
153 };
154 out.extend(remap_wrapped_line(
155 line,
156 adaptive_wrap_line(&line.line, options)
157 .into_iter()
158 .map(|wrapped| line_to_static(&wrapped))
159 .collect(),
160 ));
161 }
162 out
163}
164
165pub fn annotate_web_urls(lines: Vec<Line<'static>>) -> Vec<HyperlinkLine> {
166 lines.into_iter().map(annotate_web_urls_in_line).collect()
167}
168
169pub fn annotate_web_urls_in_line(line: Line<'static>) -> HyperlinkLine {
170 let text = line
171 .spans
172 .iter()
173 .map(|span| span.content.as_ref())
174 .collect::<String>();
175 let mut out = HyperlinkLine::new(line);
176 out.hyperlinks = web_links_in_text(&text);
177 out
178}
179
180pub fn remap_wrapped_line(
186 source: &HyperlinkLine,
187 wrapped: Vec<Line<'static>>,
188) -> Vec<HyperlinkLine> {
189 let mut out = plain_hyperlink_lines(wrapped);
190 let source_text = line_text(&source.line);
191 let mut source_byte = 0usize;
192 let mut source_column = 0usize;
193 for (index, line) in out.iter_mut().enumerate() {
194 if index > 0 {
195 let trimmed = source_text[source_byte..].trim_start_matches(char::is_whitespace);
196 let skipped = source_text[source_byte..].len() - trimmed.len();
197 source_column += source_text[source_byte..source_byte + skipped].width();
198 source_byte += skipped;
199 }
200
201 let rendered = line_text(&line.line);
202 let remaining = &source_text[source_byte..];
203 let Some(rendered_start) = longest_suffix_matching_prefix(&rendered, remaining) else {
204 continue;
205 };
206 let mapped = &rendered[rendered_start..];
207 let mut output_column = rendered[..rendered_start].width();
208 for ch in mapped.chars() {
209 let width = ch.width().unwrap_or(0);
210 if let Some(link) = source
211 .hyperlinks
212 .iter()
213 .find(|link| link.columns.contains(&source_column))
214 {
215 push_link_range(
216 line,
217 output_column..output_column + width,
218 &link.destination,
219 );
220 }
221 source_column += width;
222 output_column += width;
223 }
224 source_byte += mapped.len();
225 }
226 out
227}
228
229fn line_text(line: &Line<'_>) -> String {
230 line.spans
231 .iter()
232 .map(|span| span.content.as_ref())
233 .collect()
234}
235
236fn longest_suffix_matching_prefix(rendered: &str, source: &str) -> Option<usize> {
237 rendered
238 .char_indices()
239 .map(|(index, _)| index)
240 .chain(std::iter::once(rendered.len()))
241 .find(|index| source.starts_with(&rendered[*index..]) && *index < rendered.len())
242}
243
244fn push_link_range(line: &mut HyperlinkLine, range: Range<usize>, destination: &str) {
245 if range.is_empty() {
246 return;
247 }
248 if let Some(previous) = line.hyperlinks.last_mut() {
249 if previous.destination == destination && previous.columns.end == range.start {
250 previous.columns.end = range.end;
251 return;
252 }
253 }
254 line.hyperlinks.push(TerminalHyperlink {
255 columns: range,
256 destination: destination.to_string(),
257 });
258}
259
260pub fn web_links_in_text(text: &str) -> Vec<TerminalHyperlink> {
261 let mut links = Vec::new();
262 let mut search_from = 0usize;
263 for raw_token in text.split_ascii_whitespace() {
264 let Some(relative_start) = text[search_from..].find(raw_token) else {
265 continue;
266 };
267 let raw_start = search_from + relative_start;
268 search_from = raw_start + raw_token.len();
269 let trimmed_start = raw_token
270 .find(|ch: char| !is_leading_punctuation(ch))
271 .unwrap_or(raw_token.len());
272 let trimmed_end = trailing_url_end(&raw_token[trimmed_start..]) + trimmed_start;
273 if trimmed_start >= trimmed_end {
274 continue;
275 }
276 let candidate = &raw_token[trimmed_start..trimmed_end];
277 let Some(destination) = web_destination(candidate) else {
278 continue;
279 };
280 let start = text[..raw_start + trimmed_start].width();
281 let end = start + candidate.width();
282 links.push(TerminalHyperlink {
283 columns: start..end,
284 destination,
285 });
286 }
287 links
288}
289
290fn is_leading_punctuation(ch: char) -> bool {
291 matches!(
292 ch,
293 '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ',' | '.' | ';' | '!' | '\'' | '"'
294 )
295}
296
297fn trailing_url_end(candidate: &str) -> usize {
298 let mut end = candidate.len();
299 while end > 0 {
300 let remaining = &candidate[..end];
301 let Some(ch) = remaining.chars().next_back() else {
302 break;
303 };
304 let trim = matches!(ch, ',' | '.' | ';' | '!' | '\'' | '"')
305 || matches!(ch, ')' | ']' | '}' | '>')
306 && has_unmatched_closing_delimiter(remaining, ch);
307 if !trim {
308 break;
309 }
310 end -= ch.len_utf8();
311 }
312 end
313}
314
315fn has_unmatched_closing_delimiter(candidate: &str, closing: char) -> bool {
316 let opening = match closing {
317 ')' => '(',
318 ']' => '[',
319 '}' => '{',
320 '>' => '<',
321 _ => return false,
322 };
323 candidate.chars().filter(|ch| *ch == closing).count()
324 > candidate.chars().filter(|ch| *ch == opening).count()
325}
326
327pub fn web_destination(destination: &str) -> Option<String> {
328 let safe_destination = destination
329 .chars()
330 .filter(|ch| !ch.is_control())
331 .collect::<String>();
332 let parsed = Url::parse(&safe_destination).ok()?;
333 matches!(parsed.scheme(), "http" | "https")
334 .then(|| parsed.host_str())
335 .flatten()?;
336 Some(safe_destination)
337}
338
339pub fn osc8_hyperlink(destination: &str, text: &str) -> String {
340 let Some(safe_destination) = web_destination(destination) else {
341 return text.to_string();
342 };
343 format!("\x1b]8;;{safe_destination}\x07{text}\x1b]8;;\x07")
344}
345
346#[cfg(test)]
347pub fn strip_osc8(text: &str) -> String {
348 let bytes = text.as_bytes();
349 let mut stripped = String::with_capacity(text.len());
350 let mut index = 0usize;
351
352 while index < bytes.len() {
353 if bytes[index..].starts_with(b"\x1b]8;;") {
354 index += 5;
355 while index < bytes.len() {
356 if bytes[index] == b'\x07' {
357 index += 1;
358 break;
359 }
360 if index + 1 < bytes.len() && bytes[index] == b'\x1b' && bytes[index + 1] == b'\\' {
361 index += 2;
362 break;
363 }
364 index += 1;
365 }
366 continue;
367 }
368 let ch = text[index..]
369 .chars()
370 .next()
371 .expect("current byte index starts a character");
372 stripped.push(ch);
373 index += ch.len_utf8();
374 }
375
376 stripped
377}
378
379pub fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
380 if line.hyperlinks.is_empty() {
381 return line.line.spans.clone();
382 }
383
384 let mut out = Vec::new();
385 let mut column = 0usize;
386 let mut link_index = 0usize;
387 let mut active_link_index = None;
388 let mut active_destination: Option<String> = None;
389 for span in &line.line.spans {
390 for ch in span.content.chars() {
391 let width = ch.width().unwrap_or(0);
392 while line
393 .hyperlinks
394 .get(link_index)
395 .is_some_and(|link| link.columns.end <= column)
396 {
397 link_index += 1;
398 }
399 let selected_link_index = line
400 .hyperlinks
401 .get(link_index)
402 .and_then(|link| link.columns.contains(&column).then_some(link_index));
403 if active_link_index != selected_link_index {
404 if active_destination.is_some() {
405 append_to_last_span(&mut out, "\x1b]8;;\x07");
406 }
407 active_destination = selected_link_index
408 .and_then(|index| web_destination(&line.hyperlinks[index].destination));
409 if let Some(destination) = active_destination.as_ref() {
410 push_styled_content(
411 &mut out,
412 &format!("\x1b]8;;{destination}\x07"),
413 span.style,
414 );
415 }
416 active_link_index = selected_link_index;
417 }
418 push_styled_content(&mut out, &ch.to_string(), span.style);
419 column += width;
420 }
421 }
422 if active_destination.is_some() {
423 append_to_last_span(&mut out, "\x1b]8;;\x07");
424 }
425 out
426}
427
428fn push_styled_content(out: &mut Vec<Span<'static>>, content: &str, style: ratatui::style::Style) {
429 if let Some(last) = out.last_mut() {
430 if last.style == style {
431 last.content.to_mut().push_str(content);
432 return;
433 }
434 }
435 out.push(Span::styled(content.to_string(), style));
436}
437
438fn append_to_last_span(out: &mut [Span<'static>], content: &str) {
439 if let Some(last) = out.last_mut() {
440 last.content.to_mut().push_str(content);
441 }
442}
443
444pub fn mark_buffer_hyperlinks(
445 buf: &mut Buffer,
446 area: Rect,
447 lines: &[HyperlinkLine],
448 scroll_rows: usize,
449) {
450 if area.width == 0 {
451 return;
452 }
453 let mut logical_row = 0usize;
454 for line in lines {
455 let paragraph = Paragraph::new(Text::from(line.line.clone())).wrap(Wrap { trim: false });
456 let rendered_height = paragraph.line_count(area.width).max(1);
457 if line.hyperlinks.is_empty() {
458 logical_row += rendered_height;
459 continue;
460 }
461
462 let layout_area = Rect::new(
463 0,
464 0,
465 area.width,
466 u16::try_from(rendered_height).unwrap_or(u16::MAX),
467 );
468 let mut layout = Buffer::empty(layout_area);
469 paragraph.render(layout_area, &mut layout);
470 let rendered_lines = (0..layout_area.height)
471 .map(|row| {
472 let text = (0..layout_area.width)
473 .filter_map(|column| {
474 let cell = &layout[(column, row)];
475 (cell.diff_option != CellDiffOption::Skip).then(|| cell.symbol())
476 })
477 .collect::<String>();
478 Line::from(text.trim_end().to_string())
479 })
480 .collect();
481 for (row, rendered) in remap_wrapped_line(line, rendered_lines).iter().enumerate() {
482 for link in &rendered.hyperlinks {
483 for column in link.columns.clone() {
484 let row = logical_row + row;
485 if row < scroll_rows || row - scroll_rows >= usize::from(area.height) {
486 continue;
487 }
488 let x = area.x + column as u16;
489 let y = area.y + (row - scroll_rows) as u16;
490 let cell = &mut buf[(x, y)];
491 if cell.diff_option == CellDiffOption::Skip || cell.symbol().trim().is_empty() {
492 continue;
493 }
494 let symbol = osc8_hyperlink(&link.destination, cell.symbol());
495 cell.set_symbol(&symbol);
496 }
497 }
498 }
499 logical_row += rendered_height;
500 }
501}
502
503pub fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, destination: &str) {
504 mark_matching_cells(buf, area, destination, |cell| {
505 cell.fg == Color::Cyan && cell.modifier.contains(Modifier::UNDERLINED)
506 });
507}
508
509pub fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, destination: &str) {
510 mark_matching_cells(buf, area, destination, |cell| {
511 cell.modifier.contains(Modifier::UNDERLINED)
512 });
513}
514
515fn mark_matching_cells(
516 buf: &mut Buffer,
517 area: Rect,
518 destination: &str,
519 matches: impl Fn(&ratatui::buffer::Cell) -> bool,
520) {
521 if web_destination(destination).is_none() {
522 return;
523 }
524 for position in area.positions() {
525 let cell = &mut buf[position];
526 if cell.diff_option != CellDiffOption::Skip
527 && !cell.symbol().trim().is_empty()
528 && matches(cell)
529 {
530 let symbol = osc8_hyperlink(destination, cell.symbol());
531 cell.set_symbol(&symbol);
532 }
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use pretty_assertions::assert_eq;
540
541 #[test]
542 fn only_web_destinations_receive_osc8() {
543 assert!(osc8_hyperlink("https://example.com/a", "a").contains("\x1b]8;;"));
544 assert_eq!(osc8_hyperlink("mailto:a@example.com", "a"), "a");
545 assert_eq!(
546 osc8_hyperlink("https://example.com/\u{7}safe", "a"),
547 "\x1b]8;;https://example.com/safe\x07a\x1b]8;;\x07"
548 );
549 assert_eq!(
550 strip_osc8(&osc8_hyperlink("https://example.com/a", "visible")),
551 "visible"
552 );
553 }
554
555 #[test]
556 fn discovers_punctuated_web_url_columns() {
557 assert_eq!(
558 web_links_in_text("See (https://example.com/a)."),
559 vec![TerminalHyperlink {
560 columns: 5..26,
561 destination: "https://example.com/a".to_string(),
562 }]
563 );
564 }
565
566 #[test]
567 fn preserves_balanced_parentheses_in_bare_web_urls() {
568 let destination = "https://en.wikipedia.org/wiki/Function_(mathematics)";
569 assert_eq!(
570 web_links_in_text(&format!("See ({destination}).")),
571 vec![TerminalHyperlink {
572 columns: 5..5 + destination.width(),
573 destination: destination.to_string(),
574 }]
575 );
576 }
577
578 #[test]
579 fn decorates_a_contiguous_web_link_with_one_osc8_pair() {
580 let destination = "https://example.com/a/very/long/path";
581 let line = HyperlinkLine {
582 line: Line::from(destination),
583 hyperlinks: vec![TerminalHyperlink {
584 columns: 0..destination.width(),
585 destination: destination.to_string(),
586 }],
587 };
588
589 assert_eq!(
590 decorate_spans(&line),
591 vec![Span::from(osc8_hyperlink(destination, destination))]
592 );
593 assert_eq!(
594 decorate_spans(&HyperlinkLine::new(Line::from("not linked"))),
595 vec![Span::from("not linked")]
596 );
597 }
598
599 #[test]
600 fn wrapping_maps_repeated_link_labels_by_source_position() {
601 let mut source = HyperlinkLine::new(Line::from("here here"));
602 source.hyperlinks.push(TerminalHyperlink {
603 columns: 5..9,
604 destination: "https://example.com".to_string(),
605 });
606
607 let wrapped = remap_wrapped_line(&source, vec![Line::from("here here")]);
608
609 assert_eq!(
610 wrapped[0].hyperlinks,
611 vec![TerminalHyperlink {
612 columns: 5..9,
613 destination: "https://example.com".to_string(),
614 }]
615 );
616 }
617
618 #[test]
619 fn buffer_hyperlinks_follow_word_wrapping() {
620 let destination = "https://example.com/path";
621 let mut line = HyperlinkLine::new(Line::from(format!("See {destination} now")));
622 line.hyperlinks.push(TerminalHyperlink {
623 columns: 4..4 + destination.width(),
624 destination: destination.to_string(),
625 });
626 let area = Rect::new(
627 0, 0, 18, 4,
628 );
629 let mut buf = Buffer::empty(area);
630
631 Paragraph::new(Text::from(line.line.clone()))
632 .wrap(Wrap { trim: false })
633 .render(area, &mut buf);
634 mark_buffer_hyperlinks(&mut buf, area, &[line], 0);
635
636 let linked_text = area
637 .positions()
638 .filter_map(|position| {
639 let symbol = buf[position].symbol();
640 symbol
641 .contains(&format!("\x1b]8;;{destination}\x07"))
642 .then(|| strip_osc8(symbol))
643 })
644 .collect::<String>();
645 assert_eq!(linked_text, destination);
646 }
647}