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
346pub fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
347 if line.hyperlinks.is_empty() {
348 return line.line.spans.clone();
349 }
350
351 let mut out = Vec::new();
352 let mut column = 0usize;
353 let mut link_index = 0usize;
354 let mut active_link_index = None;
355 let mut active_destination: Option<String> = None;
356 for span in &line.line.spans {
357 for ch in span.content.chars() {
358 let width = ch.width().unwrap_or(0);
359 while line
360 .hyperlinks
361 .get(link_index)
362 .is_some_and(|link| link.columns.end <= column)
363 {
364 link_index += 1;
365 }
366 let selected_link_index = line
367 .hyperlinks
368 .get(link_index)
369 .and_then(|link| link.columns.contains(&column).then_some(link_index));
370 if active_link_index != selected_link_index {
371 if active_destination.is_some() {
372 append_to_last_span(&mut out, "\x1b]8;;\x07");
373 }
374 active_destination = selected_link_index
375 .and_then(|index| web_destination(&line.hyperlinks[index].destination));
376 if let Some(destination) = active_destination.as_ref() {
377 push_styled_content(
378 &mut out,
379 &format!("\x1b]8;;{destination}\x07"),
380 span.style,
381 );
382 }
383 active_link_index = selected_link_index;
384 }
385 push_styled_content(&mut out, &ch.to_string(), span.style);
386 column += width;
387 }
388 }
389 if active_destination.is_some() {
390 append_to_last_span(&mut out, "\x1b]8;;\x07");
391 }
392 out
393}
394
395fn push_styled_content(out: &mut Vec<Span<'static>>, content: &str, style: ratatui::style::Style) {
396 if let Some(last) = out.last_mut() {
397 if last.style == style {
398 last.content.to_mut().push_str(content);
399 return;
400 }
401 }
402 out.push(Span::styled(content.to_string(), style));
403}
404
405fn append_to_last_span(out: &mut [Span<'static>], content: &str) {
406 if let Some(last) = out.last_mut() {
407 last.content.to_mut().push_str(content);
408 }
409}
410
411pub fn mark_buffer_hyperlinks(
412 buf: &mut Buffer,
413 area: Rect,
414 lines: &[HyperlinkLine],
415 scroll_rows: usize,
416) {
417 if area.width == 0 {
418 return;
419 }
420 let mut logical_row = 0usize;
421 for line in lines {
422 let paragraph = Paragraph::new(Text::from(line.line.clone())).wrap(Wrap { trim: false });
423 let rendered_height = paragraph.line_count(area.width).max(1);
424 if line.hyperlinks.is_empty() {
425 logical_row += rendered_height;
426 continue;
427 }
428
429 let layout_area = Rect::new(
430 0,
431 0,
432 area.width,
433 u16::try_from(rendered_height).unwrap_or(u16::MAX),
434 );
435 let mut layout = Buffer::empty(layout_area);
436 paragraph.render(layout_area, &mut layout);
437 let rendered_lines = (0..layout_area.height)
438 .map(|row| {
439 let text = (0..layout_area.width)
440 .filter_map(|column| {
441 let cell = &layout[(column, row)];
442 (cell.diff_option != CellDiffOption::Skip).then(|| cell.symbol())
443 })
444 .collect::<String>();
445 Line::from(text.trim_end().to_string())
446 })
447 .collect();
448 for (row, rendered) in remap_wrapped_line(line, rendered_lines).iter().enumerate() {
449 for link in &rendered.hyperlinks {
450 for column in link.columns.clone() {
451 let row = logical_row + row;
452 if row < scroll_rows || row - scroll_rows >= usize::from(area.height) {
453 continue;
454 }
455 let x = area.x + column as u16;
456 let y = area.y + (row - scroll_rows) as u16;
457 let cell = &mut buf[(x, y)];
458 if cell.diff_option == CellDiffOption::Skip || cell.symbol().trim().is_empty() {
459 continue;
460 }
461 let symbol = osc8_hyperlink(&link.destination, cell.symbol());
462 cell.set_symbol(&symbol);
463 }
464 }
465 }
466 logical_row += rendered_height;
467 }
468}
469
470pub fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, destination: &str) {
471 mark_matching_cells(buf, area, destination, |cell| {
472 cell.fg == Color::Cyan && cell.modifier.contains(Modifier::UNDERLINED)
473 });
474}
475
476pub fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, destination: &str) {
477 mark_matching_cells(buf, area, destination, |cell| {
478 cell.modifier.contains(Modifier::UNDERLINED)
479 });
480}
481
482fn mark_matching_cells(
483 buf: &mut Buffer,
484 area: Rect,
485 destination: &str,
486 matches: impl Fn(&ratatui::buffer::Cell) -> bool,
487) {
488 if web_destination(destination).is_none() {
489 return;
490 }
491 for position in area.positions() {
492 let cell = &mut buf[position];
493 if cell.diff_option != CellDiffOption::Skip
494 && !cell.symbol().trim().is_empty()
495 && matches(cell)
496 {
497 let symbol = osc8_hyperlink(destination, cell.symbol());
498 cell.set_symbol(&symbol);
499 }
500 }
501}