use ratatui::buffer::{Buffer, CellDiffOption, CellWidth};
use ratatui::layout::Rect;
use ratatui::style::Style;
use std::num::NonZeroU16;
use std::ops::Range;
pub(super) fn draw(buf: &mut Buffer, x: u16, y: u16, label: &str, url: &str, style: Style) {
let room = buf.area.right().saturating_sub(x) as usize;
let (end, _) = buf.set_stringn(x, y, label, room, style);
link(buf, y, x..end, url);
}
pub(super) fn link(buf: &mut Buffer, y: u16, columns: Range<u16>, url: &str) {
if !cfg!(test) && !supported() {
return;
}
let url = sanitise(url);
if url.is_empty() {
return;
}
let id = id_for(&url);
let area = buf.area;
if y < area.top() || y >= area.bottom() {
return;
}
let end = columns.end.min(area.right());
let mut x = columns.start.max(area.left());
while x < end {
let start = x;
let style = buf[(x, y)].style();
let mut label = String::new();
while x < end
&& buf[(x, y)].diff_option == CellDiffOption::None
&& buf[(x, y)].style() == style
{
let symbol = buf[(x, y)].symbol();
let symbol = if symbol.is_empty() { " " } else { symbol };
label.extend(symbol.chars().filter(|c| !c.is_control()));
x = x.saturating_add(symbol.cell_width().max(1));
}
let Some(width) = NonZeroU16::new(x - start) else {
x += 1;
continue;
};
for covered in start + 1..x.min(area.right()) {
buf[(covered, y)].set_diff_option(CellDiffOption::Skip);
}
buf[(start, y)]
.set_symbol(&format!("\x1b]8;id={id};{url}\x1b\\{label}\x1b]8;;\x1b\\"))
.set_diff_option(CellDiffOption::ForcedWidth(width));
}
}
pub(super) fn link_shown(
buf: &mut Buffer,
area: Rect,
from: u16,
shown: &str,
url: &str,
) -> Option<u16> {
let area = area.intersection(buf.area);
for y in from.max(area.top())..area.bottom() {
let mut text = String::new();
let mut bounds = Vec::new();
let mut x = area.left();
while x < area.right() {
let symbol = buf[(x, y)].symbol();
bounds.push((text.len(), x));
text.push_str(symbol);
x = x.saturating_add(symbol.cell_width().max(1));
}
bounds.push((text.len(), x));
let Some(at) = text.find(shown) else {
continue;
};
let column = |offset: usize| bounds.iter().find(|(o, _)| *o == offset).map(|b| b.1);
if let (Some(start), Some(end)) = (column(at), column(at + shown.len())) {
link(buf, y, start..end, url);
return Some(y);
}
}
None
}
pub(super) fn supported() -> bool {
static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*SUPPORTED.get_or_init(|| supported_by(std::env::var("TERM").ok().as_deref()))
}
fn supported_by(term: Option<&str>) -> bool {
!matches!(term, Some("dumb" | "linux"))
}
fn sanitise(url: &str) -> String {
url.chars().filter(|c| !c.is_control()).collect()
}
fn id_for(url: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
format!("cctop-{:x}", hasher.finish())
}
#[cfg(test)]
pub(crate) fn shown_in(symbol: &str) -> String {
match label_of(symbol) {
Some(label) => label.chars().next().map(String::from).unwrap_or_default(),
None => symbol.to_string(),
}
}
#[cfg(test)]
pub(crate) fn visible(buf: &Buffer) -> String {
let mut out = String::new();
let mut skip = 0u16;
for (i, cell) in buf.content().iter().enumerate() {
if i > 0 && i % buf.area.width as usize == 0 {
out.push('\n');
skip = 0;
}
if skip > 0 {
skip -= 1;
continue;
}
match (label_of(cell.symbol()), cell.diff_option) {
(Some(label), CellDiffOption::ForcedWidth(width)) => {
out.push_str(label);
skip = width.get() - 1;
}
_ => out.push_str(cell.symbol()),
}
}
out
}
#[cfg(test)]
pub(crate) fn target_of(symbol: &str) -> Option<&str> {
let rest = symbol.strip_prefix("\x1b]8;")?;
let (_, rest) = rest.split_once(';')?;
rest.split_once("\x1b\\").map(|(url, _)| url)
}
#[cfg(test)]
fn label_of(symbol: &str) -> Option<&str> {
let rest = symbol.strip_prefix("\x1b]8;")?;
let (_, rest) = rest.split_once("\x1b\\")?;
rest.strip_suffix("\x1b]8;;\x1b\\")
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::{Color, Modifier};
const URL: &str = "https://github.com/ratatui/";
#[test]
fn one_cell_carries_the_link_and_the_rest_are_skipped() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
draw(&mut buf, 0, 0, "ratatui", URL, Style::default());
let first = &buf[(0, 0)];
assert_eq!(target_of(first.symbol()), Some(URL));
assert_eq!(label_of(first.symbol()), Some("ratatui"));
assert_eq!(
first.diff_option,
CellDiffOption::ForcedWidth(NonZeroU16::new(7).unwrap())
);
for x in 1..7 {
assert_eq!(buf[(x, 0)].diff_option, CellDiffOption::Skip, "column {x}");
}
assert_eq!(buf[(7, 0)].diff_option, CellDiffOption::None);
assert_eq!(visible(&buf).trim_end(), "ratatui");
}
#[test]
fn what_follows_a_link_is_still_drawn_where_it_belongs() {
let before = Buffer::empty(Rect::new(0, 0, 20, 1));
let mut after = Buffer::empty(Rect::new(0, 0, 20, 1));
draw(&mut after, 0, 0, "ratatui", URL, Style::default());
after.set_string(8, 0, "next", Style::default());
let drawn: Vec<(u16, &str)> = before
.diff(&after)
.into_iter()
.map(|(x, _, cell)| (x, cell.symbol()))
.collect();
assert_eq!(drawn[0].0, 0);
assert_eq!(
drawn[1..].iter().map(|(x, s)| (*x, *s)).collect::<Vec<_>>(),
vec![(8, "n"), (9, "e"), (10, "x"), (11, "t")]
);
}
#[test]
fn a_link_is_free_to_keep_and_erased_whole_when_it_goes() {
let mut linked = Buffer::empty(Rect::new(0, 0, 20, 1));
draw(&mut linked, 2, 0, "a b", URL, Style::default());
let mut again = Buffer::empty(Rect::new(0, 0, 20, 1));
draw(&mut again, 2, 0, "a b", URL, Style::default());
assert!(linked.diff(&again).is_empty());
let blank = Buffer::empty(Rect::new(0, 0, 20, 1));
let erased: Vec<u16> = linked.diff(&blank).iter().map(|(x, _, _)| *x).collect();
assert_eq!(erased, vec![2, 3, 4]);
}
#[test]
fn a_change_of_style_splits_the_run_but_not_the_link() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
buf.set_string(0, 0, "abc", Style::default());
let underlined = Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED);
buf.set_string(3, 0, "def", underlined);
link(&mut buf, 0, 0..6, URL);
assert_eq!(label_of(buf[(0, 0)].symbol()), Some("abc"));
assert_eq!(label_of(buf[(3, 0)].symbol()), Some("def"));
assert_eq!(buf[(3, 0)].style().fg, Some(Color::Blue));
let id = |x: u16| buf[(x, 0)].symbol().split(';').nth(1).map(str::to_owned);
assert_eq!(id(0), id(3));
assert_eq!(visible(&buf).trim_end(), "abcdef");
}
#[test]
fn a_wide_glyph_counts_its_columns_once() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
draw(&mut buf, 0, 0, "コx", URL, Style::default());
assert_eq!(label_of(buf[(0, 0)].symbol()), Some("コx"));
assert_eq!(
buf[(0, 0)].diff_option,
CellDiffOption::ForcedWidth(NonZeroU16::new(3).unwrap())
);
}
#[test]
fn a_label_past_the_edge_is_cut_there() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
draw(&mut buf, 1, 0, "ratatui", URL, Style::default());
assert_eq!(label_of(buf[(1, 0)].symbol()), Some("rat"));
assert_eq!(visible(&buf), " rat");
}
#[test]
fn a_control_character_in_the_url_goes_nowhere() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
draw(
&mut buf,
0,
0,
"x",
"https://a\x1b]0;pwned\x07/",
Style::default(),
);
assert_eq!(target_of(buf[(0, 0)].symbol()), Some("https://a]0;pwned/"));
}
#[test]
fn only_the_terminals_known_to_print_it_go_without() {
assert!(!supported_by(Some("dumb")));
assert!(!supported_by(Some("linux")));
assert!(supported_by(Some("xterm-256color")));
assert!(supported_by(Some("tmux-256color")));
assert!(supported_by(None));
}
#[test]
fn shown_text_is_linked_where_it_landed() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
buf.set_string(1, 0, "go http://h:1 now", Style::default());
buf.set_string(4, 2, "http://h:1", Style::default());
let area = buf.area;
let row = link_shown(&mut buf, area, 0, "http://h:1", "http://h:1/?t=a");
assert_eq!(row, Some(0));
assert_eq!(target_of(buf[(4, 0)].symbol()), Some("http://h:1/?t=a"));
let row = link_shown(&mut buf, area, 1, "http://h:1", "http://h:1/?t=b");
assert_eq!(row, Some(2));
assert_eq!(target_of(buf[(4, 2)].symbol()), Some("http://h:1/?t=b"));
assert_eq!(link_shown(&mut buf, area, 0, "absent", URL), None);
}
}