use std::num::NonZeroU16;
use ratatui::buffer::{Buffer, CellDiffOption};
use unicode_width::UnicodeWidthStr;
pub fn linkable(url: &str) -> bool {
(url.starts_with("http://") || url.starts_with("https://"))
&& url.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'-' | b'.'
| b'_'
| b'~'
| b':'
| b'/'
| b'?'
| b'#'
| b'['
| b']'
| b'@'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
| b'%'
)
})
}
pub fn linkify(buf: &mut Buffer, y: u16, x0: u16, x1: u16, url: &str, id: &str) {
if !linkable(url) {
return;
}
let id: String = id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
let open = format!("\x1b]8;id={id};{url}\x1b\\");
let mut x = x0;
while x < x1 {
let Some(cell) = buf.cell_mut((x, y)) else {
return;
};
let glyph = cell.symbol().to_string();
let width = glyph.width().max(1) as u16;
cell.set_symbol(&format!("{open}{glyph}\x1b]8;;\x1b\\"));
cell.set_diff_option(CellDiffOption::ForcedWidth(
NonZeroU16::new(width).expect("width floored at 1"),
));
x += width;
}
}
#[cfg(test)]
pub fn without_links(symbol: &str) -> String {
let mut out = String::with_capacity(symbol.len());
let mut rest = symbol;
while let Some(start) = rest.find("\x1b]8;") {
out.push_str(&rest[..start]);
let after = &rest[start + 4..];
match after.find("\x1b\\") {
Some(end) => rest = &after[end + 2..],
None => return out, }
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use super::*;
fn buffer_with(text: &str) -> Buffer {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 2));
buf.set_string(0, 0, text, Style::default().add_modifier(Modifier::BOLD));
buf
}
#[test]
fn a_linked_cell_is_self_contained_and_keeps_its_width() {
let mut buf = buffer_with("news");
linkify(&mut buf, 0, 0, 4, "https://example.com/a", "story-1");
let cell = buf.cell((0, 0)).unwrap();
assert_eq!(
cell.symbol(),
"\x1b]8;id=story-1;https://example.com/a\x1b\\n\x1b]8;;\x1b\\",
"open sequence, the glyph, close sequence — nothing shared \
between cells, so any subset the diff re-emits is consistent"
);
assert_eq!(
cell.diff_option,
CellDiffOption::ForcedWidth(NonZeroU16::new(1).unwrap()),
"the declared width is the glyph's, or every column after \
this cell drifts"
);
assert!(
cell.modifier.contains(Modifier::BOLD),
"linkify must not disturb the style the widget chose"
);
}
#[test]
fn a_wide_glyph_keeps_its_width_and_its_continuation_cell() {
let mut buf = buffer_with("日x");
linkify(&mut buf, 0, 0, 3, "https://example.com/w", "story-2");
let wide = buf.cell((0, 0)).unwrap();
assert!(wide.symbol().contains('日'));
assert_eq!(
wide.diff_option,
CellDiffOption::ForcedWidth(NonZeroU16::new(2).unwrap()),
"a double-width glyph must declare both its columns"
);
let continuation = buf.cell((1, 0)).unwrap();
assert!(
!continuation.symbol().contains('\x1b'),
"the continuation cell is the covered half of its neighbour and \
must not grow sequences of its own; it holds {:?}",
continuation.symbol()
);
assert_eq!(continuation.diff_option, CellDiffOption::None);
let after = buf.cell((2, 0)).unwrap();
assert!(
after.symbol().contains('x') && after.symbol().contains("]8;"),
"the glyph after the wide one is back on a boundary and linked"
);
}
#[test]
fn an_unsafe_url_links_nothing() {
for url in [
"https://example.com/\x1b]8;;evil",
"https://example.com/\x07",
"file:///etc/passwd",
"javascript:alert(1)",
"",
] {
let mut buf = buffer_with("headline");
linkify(&mut buf, 0, 0, 8, url, "story-3");
for x in 0..8 {
let cell = buf.cell((x, 0)).unwrap();
assert!(
!cell.symbol().contains('\x1b'),
"url {url:?} must not reach the terminal, in a link or \
otherwise; cell {x} holds {:?}",
cell.symbol()
);
assert_eq!(cell.diff_option, CellDiffOption::None);
}
}
}
#[test]
fn a_range_past_the_buffer_edge_is_clipped_not_fatal() {
let mut buf = buffer_with("ok");
linkify(&mut buf, 0, 0, 200, "https://example.com/e", "story-4");
linkify(&mut buf, 9, 0, 4, "https://example.com/e", "story-4");
assert!(buf.cell((0, 0)).unwrap().symbol().contains("]8;"));
}
#[test]
fn stripping_links_recovers_exactly_the_visible_text() {
let mut buf = buffer_with("ab 日");
linkify(&mut buf, 0, 0, 5, "https://example.com/s", "story-5");
let text: String = (0..5)
.filter_map(|x| buf.cell((x, 0)).map(|c| without_links(c.symbol())))
.collect();
assert_eq!(text, "ab 日 ");
assert_eq!(without_links("plain"), "plain");
}
}