use std::io::{self, Write};
use std::num::NonZeroU16;
use crossterm::queue;
use crossterm::style::{
Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
SetForegroundColor,
};
use ratatui_core::backend::{Backend, ClearType, WindowSize};
use ratatui_core::buffer::{Buffer, Cell, CellDiffOption};
use ratatui_core::layout::{Position, Rect, Size};
use ratatui_core::style::{Color, Modifier};
use ratatui_core::text::{Line, Span};
use ratatui_crossterm::CrosstermBackend;
const ST: &str = "\x1b\\";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LinkPolicy {
web: bool,
mailto: bool,
}
impl LinkPolicy {
pub const NONE: Self = Self {
web: false,
mailto: false,
};
pub const WEB: Self = Self {
web: true,
mailto: false,
};
pub const fn with_mailto(mut self) -> Self {
self.mailto = true;
self
}
pub const fn links_any(self) -> bool {
self.web || self.mailto
}
}
impl Default for LinkPolicy {
fn default() -> Self {
Self::WEB
}
}
const WEB_PREFIXES: [&str; 2] = ["https://", "http://"];
const MAILTO_PREFIX: &str = "mailto:";
pub fn osc8_with(url: &str, text: &str, policy: LinkPolicy) -> String {
match sanitize_url(url, policy) {
Some(url) => format!("\x1b]8;;{url}{ST}{text}\x1b]8;;{ST}"),
None => text.to_string(),
}
}
pub fn osc8(url: &str, text: &str) -> String {
osc8_with(url, text, LinkPolicy::default())
}
pub fn is_web_url(s: &str) -> bool {
(s.starts_with("http://") || s.starts_with("https://")) && !s.chars().any(char::is_whitespace)
}
fn is_linkable(s: &str, policy: LinkPolicy) -> bool {
if s.chars().any(char::is_whitespace) {
return false;
}
(policy.web && WEB_PREFIXES.iter().any(|p| s.starts_with(p)))
|| (policy.mailto && s.starts_with(MAILTO_PREFIX))
}
fn strip_controls(s: &str) -> String {
s.chars()
.filter(|&c| !c.is_control() && c != '\u{7f}')
.collect()
}
fn sanitize_url(url: &str, policy: LinkPolicy) -> Option<String> {
if policy.web && WEB_PREFIXES.iter().any(|p| url.starts_with(p)) {
let cleaned = strip_controls(url);
return (cleaned.len() >= "http://".len()).then_some(cleaned);
}
if policy.mailto && url.starts_with(MAILTO_PREFIX) {
let addr = url.split('?').next().unwrap_or(url);
let cleaned = strip_controls(addr);
return (cleaned.len() > MAILTO_PREFIX.len()).then_some(cleaned);
}
None
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BufferLink {
pub line: u16,
pub start_col: u16,
pub end_col: u16,
pub url: String,
}
const OSC8_OPEN: &str = "\x1b]8;;";
pub fn apply_buffer_links(
buf: &mut Buffer,
origin: Position,
links: &[BufferLink],
policy: LinkPolicy,
) {
if !policy.links_any() {
return;
}
for link in links {
let Some(url) = sanitize_url(&link.url, policy) else {
continue;
};
if link.end_col <= link.start_col {
continue;
}
let y = origin.y.saturating_add(link.line);
let xs = origin.x.saturating_add(link.start_col);
let xe = origin.x.saturating_add(link.end_col.saturating_sub(1));
if y >= buf.area.bottom() || xs >= buf.area.right() || xe >= buf.area.right() || xe < xs {
continue;
}
wrap_cell_osc8(&mut buf[(xs, y)], &url, true);
wrap_cell_osc8(&mut buf[(xe, y)], &url, false);
}
}
fn wrap_cell_osc8(cell: &mut Cell, url: &str, head: bool) {
let sym = cell.symbol();
if head {
if sym.contains(OSC8_OPEN) {
return;
}
cell.set_symbol(&format!("{OSC8_OPEN}{url}{ST}{sym}"))
.set_diff_option(CellDiffOption::ForcedWidth(NonZeroU16::new(1).unwrap()));
return;
}
if sym.contains("\x1b]8;;\x1b\\") {
return;
}
cell.set_symbol(&format!("{sym}{OSC8_OPEN}{ST}"))
.set_diff_option(CellDiffOption::ForcedWidth(NonZeroU16::new(1).unwrap()));
}
fn visible_symbol(symbol: &str) -> String {
strip_osc8(symbol)
}
fn strip_osc8(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i..].starts_with(b"\x1b]8;;") {
i += 5; while i + 1 < bytes.len() {
if bytes[i] == 0x1b && bytes[i + 1] == b'\\' {
i += 2;
break;
}
i += 1;
}
continue;
}
let ch = s[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
fn osc8_target_in(symbol: &str) -> Option<String> {
let rest = symbol.strip_prefix(OSC8_OPEN)?;
let end = rest.find(ST)?;
let url = &rest[..end];
(!url.is_empty()).then(|| url.to_string())
}
pub fn ctrl_click_url(event: &crate::Mouse, buffer: &Buffer, area: Rect) -> Option<String> {
ctrl_click_url_with(event, buffer, area, LinkPolicy::default())
}
pub fn ctrl_click_url_with(
event: &crate::Mouse,
buffer: &Buffer,
area: Rect,
policy: LinkPolicy,
) -> Option<String> {
if event.kind != crate::MouseKind::Up(crate::MouseButton::Left)
|| !event.ctrl
|| event.shift
|| event.alt
|| event.row < area.y
|| event.row >= area.bottom()
|| event.column < area.x
|| event.column >= area.right()
{
return None;
}
if let Some(url) = osc8_url_at(buffer, area, event.column, event.row)
&& sanitize_url(&url, policy).is_some()
{
return Some(url);
}
let mut row = String::new();
let mut clicked_bytes = 0..0;
for column in area.x..area.right() {
let start = row.len();
let visible = visible_symbol(buffer[(column, event.row)].symbol());
row.push_str(&visible);
if column == event.column {
clicked_bytes = start..row.len();
}
}
find_links(&row, policy)
.into_iter()
.find(|(start, end)| *start < clicked_bytes.end && clicked_bytes.start < *end)
.map(|(start, end)| row[start..end].to_string())
}
fn osc8_url_at(buffer: &Buffer, area: Rect, col: u16, row: u16) -> Option<String> {
let mut url = None;
let mut open_at = None;
for x in area.x..=col {
if let Some(u) = osc8_target_in(buffer[(x, row)].symbol()) {
url = Some(u);
open_at = Some(x);
}
}
let (url, open_at) = (url?, open_at?);
for x in open_at..=col {
if x > open_at && osc8_target_in(buffer[(x, row)].symbol()).is_some() {
return None;
}
}
let mut closed = false;
for x in col..area.right() {
let sym = buffer[(x, row)].symbol();
if sym.contains("\x1b]8;;\x1b\\") || sym.ends_with("\x1b]8;;\x1b\\") {
closed = true;
break;
}
if x > col && osc8_target_in(sym).is_some() {
return None;
}
}
closed.then_some(url)
}
fn find_links(s: &str, policy: LinkPolicy) -> Vec<(usize, usize)> {
const TRAILING: &[char] = &['.', ',', ';', ':', '!', '?', ')', ']', '}', '\'', '"'];
let mut ranges = Vec::new();
if !policy.links_any() {
return ranges;
}
let mut prefixes: Vec<(&str, bool)> = Vec::new();
if policy.web {
prefixes.extend(WEB_PREFIXES.iter().map(|&p| (p, false)));
}
if policy.mailto {
prefixes.push((MAILTO_PREFIX, true));
}
let mut offset = 0;
while offset < s.len() {
let rest = &s[offset..];
let Some((rel, prefix, is_mailto)) = prefixes
.iter()
.filter_map(|&(p, m)| rest.find(p).map(|i| (i, p, m)))
.min_by_key(|&(i, ..)| i)
else {
break;
};
let start = offset + rel;
let tail = &s[start..];
let mut raw_end = tail.find(char::is_whitespace).unwrap_or(tail.len());
if is_mailto && let Some(q) = tail[..raw_end].find('?') {
raw_end = q;
}
let len = tail[..raw_end].trim_end_matches(TRAILING).len();
if len <= prefix.len() {
offset = start + prefix.len();
continue;
}
ranges.push((start, start + len));
offset = start + len;
}
ranges
}
pub fn write_line(out: &mut impl Write, line: &Line<'_>) -> io::Result<()> {
write_line_with(out, line, LinkPolicy::default())
}
pub fn write_line_with(
out: &mut impl Write,
line: &Line<'_>,
policy: LinkPolicy,
) -> io::Result<()> {
for span in &line.spans {
write_span(out, span, policy)?;
}
queue!(out, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
fn write_span(out: &mut impl Write, span: &Span<'_>, policy: LinkPolicy) -> io::Result<()> {
apply_style(out, span)?;
let content = span.content.as_ref();
let is_link = content.trim() == content && is_linkable(content, policy);
if is_link {
queue!(out, Print(osc8_with(content, content, policy)))?;
} else {
queue!(out, Print(content))?;
}
queue!(out, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
fn apply_style(out: &mut impl Write, span: &Span<'_>) -> io::Result<()> {
let style = span.style;
if let Some(fg) = style.fg {
queue!(out, SetForegroundColor(to_ct_color(fg)))?;
}
if let Some(bg) = style.bg {
queue!(out, SetBackgroundColor(to_ct_color(bg)))?;
}
for (modifier, attribute) in [
(Modifier::BOLD, Attribute::Bold),
(Modifier::DIM, Attribute::Dim),
(Modifier::ITALIC, Attribute::Italic),
(Modifier::UNDERLINED, Attribute::Underlined),
(Modifier::CROSSED_OUT, Attribute::CrossedOut),
(Modifier::REVERSED, Attribute::Reverse),
] {
if style.add_modifier.contains(modifier) {
queue!(out, SetAttribute(attribute))?;
}
}
Ok(())
}
fn to_ct_color(color: Color) -> CtColor {
match color {
Color::Reset => CtColor::Reset,
Color::Black => CtColor::Black,
Color::Red => CtColor::DarkRed,
Color::Green => CtColor::DarkGreen,
Color::Yellow => CtColor::DarkYellow,
Color::Blue => CtColor::DarkBlue,
Color::Magenta => CtColor::DarkMagenta,
Color::Cyan => CtColor::DarkCyan,
Color::Gray => CtColor::Grey,
Color::DarkGray => CtColor::DarkGrey,
Color::LightRed => CtColor::Red,
Color::LightGreen => CtColor::Green,
Color::LightYellow => CtColor::Yellow,
Color::LightBlue => CtColor::Blue,
Color::LightMagenta => CtColor::Magenta,
Color::LightCyan => CtColor::Cyan,
Color::White => CtColor::White,
Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
Color::Indexed(i) => CtColor::AnsiValue(i),
}
}
pub struct HyperlinkBackend<W: Write> {
inner: CrosstermBackend<W>,
policy: LinkPolicy,
}
impl<W: Write> HyperlinkBackend<W> {
pub fn new(writer: W, enabled: bool) -> Self {
let policy = if enabled {
LinkPolicy::default()
} else {
LinkPolicy::NONE
};
Self::with_policy(writer, policy)
}
pub fn with_policy(writer: W, policy: LinkPolicy) -> Self {
Self {
inner: CrosstermBackend::new(writer),
policy,
}
}
fn emit_run(&mut self, run: &[(u16, u16, &Cell)]) -> io::Result<()> {
let mut text = String::new();
let mut cell_starts = Vec::with_capacity(run.len());
for (_, _, cell) in run {
cell_starts.push(text.len());
text.push_str(&visible_symbol(cell.symbol()));
}
let urls = find_links(&text, self.policy);
if urls.is_empty() {
return self.inner.draw(run.iter().copied());
}
let mut cursor = 0usize;
for (byte_start, byte_end) in urls {
let start_cell = cell_starts.partition_point(|&b| b < byte_start);
let end_cell = cell_starts.partition_point(|&b| b < byte_end);
if cursor < start_cell {
self.inner.draw(run[cursor..start_cell].iter().copied())?;
}
if start_cell < end_cell {
let sub = &run[start_cell..end_cell];
let already = sub.iter().any(|(_, _, c)| c.symbol().contains(OSC8_OPEN));
if already {
self.inner.draw(sub.iter().copied())?;
} else {
match sanitize_url(&text[byte_start..byte_end], self.policy) {
Some(url) => {
write!(self.inner, "\x1b]8;;{url}{ST}")?;
self.inner.draw(sub.iter().copied())?;
write!(self.inner, "\x1b]8;;{ST}")?;
}
None => self.inner.draw(sub.iter().copied())?,
}
}
}
cursor = end_cell.max(cursor);
}
if cursor < run.len() {
self.inner.draw(run[cursor..].iter().copied())?;
}
Ok(())
}
}
impl<W: Write> Write for HyperlinkBackend<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.inner.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
Write::flush(&mut self.inner)
}
}
impl<W: Write> Backend for HyperlinkBackend<W> {
type Error = io::Error;
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
if !self.policy.links_any() {
return self.inner.draw(content);
}
let cells: Vec<(u16, u16, &Cell)> = content.collect();
let mut i = 0;
while i < cells.len() {
let mut j = i + 1;
while j < cells.len()
&& cells[j].1 == cells[j - 1].1
&& cells[j].0 == cells[j - 1].0 + 1
{
j += 1;
}
self.emit_run(&cells[i..j])?;
i = j;
}
Ok(())
}
fn append_lines(&mut self, n: u16) -> io::Result<()> {
self.inner.append_lines(n)
}
fn hide_cursor(&mut self) -> io::Result<()> {
self.inner.hide_cursor()
}
fn show_cursor(&mut self) -> io::Result<()> {
self.inner.show_cursor()
}
fn get_cursor_position(&mut self) -> io::Result<Position> {
self.inner.get_cursor_position()
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
self.inner.set_cursor_position(position)
}
fn clear(&mut self) -> io::Result<()> {
self.inner.clear()
}
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
self.inner.clear_region(clear_type)
}
fn size(&self) -> io::Result<Size> {
self.inner.size()
}
fn window_size(&mut self) -> io::Result<WindowSize> {
self.inner.window_size()
}
fn flush(&mut self) -> io::Result<()> {
Backend::flush(&mut self.inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bytes(line: &Line<'_>) -> String {
let mut out: Vec<u8> = Vec::new();
write_line(&mut out, line).expect("write");
String::from_utf8(out).expect("utf8")
}
#[test]
fn osc8_wraps_valid_web_urls() {
assert_eq!(
osc8("https://example.com", "example"),
"\x1b]8;;https://example.com\x1b\\example\x1b]8;;\x1b\\"
);
}
#[test]
fn osc8_passes_through_non_web_or_unsafe_urls() {
assert_eq!(osc8("mailto:a@b.com", "mail"), "mail");
assert_eq!(osc8("ftp://host/x", "f"), "f");
let sneaky = "https://evil\x1b\\.com";
let encoded = osc8(sneaky, "x");
assert!(
!encoded.contains("evil\x1b"),
"raw escape must be stripped from the target: {encoded:?}"
);
assert!(encoded.starts_with("\x1b]8;;https://evil"));
}
#[test]
fn is_web_url_requires_scheme_and_no_whitespace() {
assert!(is_web_url("https://a.dev/x?y=1"));
assert!(is_web_url("http://a.dev"));
assert!(!is_web_url("a.dev"));
assert!(!is_web_url("https://a.dev x"));
}
#[test]
fn write_line_hyperlinks_url_spans_only() {
let line = Line::from(vec![
Span::raw("see "),
Span::raw("https://rust-lang.org"),
Span::raw(" now"),
]);
let out = bytes(&line);
assert!(
out.contains("\x1b]8;;https://rust-lang.org\x1b\\https://rust-lang.org\x1b]8;;\x1b\\")
);
assert!(out.contains("see "));
assert!(out.contains(" now"));
}
#[test]
fn write_line_emits_color_and_underline_then_resets() {
let line = Line::from(Span::styled(
"https://a.dev",
ratatui_core::style::Style::default()
.fg(Color::Rgb(45, 91, 158))
.add_modifier(Modifier::UNDERLINED),
));
let out = bytes(&line);
assert!(out.contains("\x1b[4m"), "underline SGR expected: {out:?}");
assert!(
out.contains("\x1b]8;;https://a.dev\x1b\\"),
"OSC 8 wrap expected: {out:?}"
);
assert!(out.trim_end().ends_with("\x1b[0m") || out.contains("\x1b[0m"));
}
#[test]
fn write_line_plain_text_has_no_osc8() {
let line = Line::from(Span::raw("no links here"));
let out = bytes(&line);
assert!(!out.contains("\x1b]8;;"));
assert!(out.contains("no links here"));
}
#[test]
fn ctrl_click_returns_visible_url_under_pointer() {
use crate::{Mouse, MouseButton, MouseKind};
use ratatui_core::{buffer::Buffer, layout::Rect, style::Style};
let area = Rect::new(3, 2, 40, 1);
let mut buffer = Buffer::empty(Rect::new(0, 0, 50, 5));
buffer.set_string(
area.x,
area.y,
"see https://example.com/docs now",
Style::default(),
);
let mut event = Mouse::at(MouseKind::Up(MouseButton::Left), 15, area.y);
event.ctrl = true;
assert_eq!(
ctrl_click_url(&event, &buffer, area).as_deref(),
Some("https://example.com/docs")
);
}
#[test]
fn ctrl_click_ignores_plain_clicks_and_non_url_text() {
use crate::{Mouse, MouseButton, MouseKind};
use ratatui_core::{buffer::Buffer, layout::Rect, style::Style};
let area = Rect::new(0, 0, 30, 1);
let mut buffer = Buffer::empty(area);
buffer.set_string(0, 0, "https://example.com plain", Style::default());
let plain = Mouse::at(MouseKind::Up(MouseButton::Left), 10, 0);
let mut text = Mouse::at(MouseKind::Up(MouseButton::Left), 23, 0);
text.ctrl = true;
assert_eq!(ctrl_click_url(&plain, &buffer, area), None);
assert_eq!(ctrl_click_url(&text, &buffer, area), None);
}
#[test]
fn find_web_urls_locates_and_trims() {
let web = LinkPolicy::default();
assert_eq!(find_links("see https://a.dev/x, ok", web), vec![(4, 19)]);
assert_eq!(
find_links("a http://x.io b https://y.io", web),
vec![(2, 13), (16, 28)]
);
assert!(find_links("no links", web).is_empty());
}
#[test]
fn mailto_is_off_under_default_policy() {
assert_eq!(osc8("mailto:a@b.com", "mail"), "mail");
assert!(find_links("write mailto:a@b.com now", LinkPolicy::default()).is_empty());
}
#[test]
fn mailto_links_when_opted_in() {
let policy = LinkPolicy::WEB.with_mailto();
assert_eq!(
osc8_with("mailto:a@b.com", "mail", policy),
"\x1b]8;;mailto:a@b.com\x1b\\mail\x1b]8;;\x1b\\"
);
assert_eq!(find_links("write mailto:a@b.com.", policy), vec![(6, 20)]);
assert_eq!(
find_links("mailto:a@b.com then https://x.io", policy),
vec![(0, 14), (20, 32)]
);
}
#[test]
fn mailto_drops_query_to_block_header_injection() {
let policy = LinkPolicy::WEB.with_mailto();
assert_eq!(
find_links("mailto:a@b.com?cc=evil@x.com&body=hi", policy),
vec![(0, 14)]
);
let encoded = osc8_with("mailto:a@b.com?cc=evil@x.com&body=hi", "m", policy);
assert_eq!(encoded, "\x1b]8;;mailto:a@b.com\x1b\\m\x1b]8;;\x1b\\");
assert!(
!encoded.contains("cc="),
"query must not reach the OSC target"
);
}
#[test]
fn mailto_strips_control_bytes_from_target() {
let policy = LinkPolicy::WEB.with_mailto();
let sneaky = "mailto:a\x1b\\@b.com";
let encoded = osc8_with(sneaky, "m", policy);
assert!(
!encoded.contains("a\x1b"),
"raw escape must be stripped: {encoded:?}"
);
assert!(encoded.starts_with("\x1b]8;;mailto:a"));
}
#[test]
fn mailto_without_address_is_not_a_link() {
let policy = LinkPolicy::WEB.with_mailto();
assert_eq!(osc8_with("mailto:", "m", policy), "m");
assert!(find_links("bare mailto: here", policy).is_empty());
}
#[derive(Clone)]
struct SharedBuf(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
impl Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.borrow_mut().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn draw_row_with(text: &str, policy: LinkPolicy) -> String {
use ratatui_core::buffer::Cell;
let cells: Vec<(u16, u16, Cell)> = text
.chars()
.enumerate()
.map(|(i, ch)| {
let mut cell = Cell::default();
cell.set_symbol(&ch.to_string());
(i as u16, 0u16, cell)
})
.collect();
let buf = SharedBuf(std::rc::Rc::new(std::cell::RefCell::new(Vec::new())));
let mut backend = HyperlinkBackend::with_policy(buf.clone(), policy);
backend
.draw(cells.iter().map(|(x, y, c)| (*x, *y, c)))
.expect("draw");
let bytes = buf.0.borrow().clone();
String::from_utf8(bytes).expect("utf8")
}
fn draw_row(text: &str, enabled: bool) -> String {
let policy = if enabled {
LinkPolicy::default()
} else {
LinkPolicy::NONE
};
draw_row_with(text, policy)
}
#[test]
fn backend_wraps_url_runs_in_osc8() {
let out = draw_row("see https://rust-lang.org now", true);
assert!(
out.contains("\x1b]8;;https://rust-lang.org\x1b\\"),
"URL run should open OSC 8: {out:?}"
);
assert!(out.contains("\x1b]8;;\x1b\\"), "URL run should close OSC 8");
assert_eq!(out.matches("\x1b]8;;https://").count(), 1);
}
#[test]
fn backend_disabled_emits_no_osc8() {
let out = draw_row("see https://rust-lang.org now", false);
assert!(
!out.contains("\x1b]8;;"),
"disabled backend must not link: {out:?}"
);
assert!(out.contains('h') && out.contains('s'));
}
#[test]
fn backend_plain_row_has_no_osc8() {
let out = draw_row("just some text", true);
assert!(!out.contains("\x1b]8;;"));
}
#[test]
fn backend_links_mailto_only_when_policy_allows() {
let row = "mail me at mailto:a@b.com today";
assert!(!draw_row_with(row, LinkPolicy::default()).contains("\x1b]8;;"));
let out = draw_row_with(row, LinkPolicy::WEB.with_mailto());
assert!(
out.contains("\x1b]8;;mailto:a@b.com\x1b\\"),
"mailto run should open OSC 8: {out:?}"
);
assert!(
out.contains("\x1b]8;;\x1b\\"),
"mailto run should close OSC 8"
);
}
#[test]
fn apply_buffer_links_makes_labeled_run_ctrl_clickable() {
use crate::{Mouse, MouseButton, MouseKind};
use ratatui_core::style::Style;
let area = Rect::new(2, 1, 20, 1);
let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 4));
buffer.set_string(area.x, area.y, "see docs here", Style::default());
let links = [BufferLink {
line: 0,
start_col: 4,
end_col: 8,
url: "https://example.com/docs".into(),
}];
apply_buffer_links(
&mut buffer,
Position {
x: area.x,
y: area.y,
},
&links,
LinkPolicy::WEB,
);
let head = buffer[(area.x + 4, area.y)].symbol();
assert!(
head.starts_with("\x1b]8;;https://example.com/docs\x1b\\"),
"opener cell: {head:?}"
);
let mut event = Mouse::at(MouseKind::Up(MouseButton::Left), area.x + 5, area.y);
event.ctrl = true;
assert_eq!(
ctrl_click_url(&event, &buffer, area).as_deref(),
Some("https://example.com/docs")
);
}
#[test]
fn apply_buffer_links_respects_none_policy() {
use ratatui_core::style::Style;
let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
buffer.set_string(0, 0, "docs", Style::default());
apply_buffer_links(
&mut buffer,
Position { x: 0, y: 0 },
&[BufferLink {
line: 0,
start_col: 0,
end_col: 4,
url: "https://example.com".into(),
}],
LinkPolicy::NONE,
);
assert_eq!(buffer[(0, 0)].symbol(), "d");
assert!(!buffer[(0, 0)].symbol().contains("\x1b]8;;"));
}
#[test]
fn strip_osc8_leaves_visible_label() {
assert_eq!(
strip_osc8("\x1b]8;;https://x.dev\x1b\\hi\x1b]8;;\x1b\\"),
"hi"
);
assert_eq!(strip_osc8("plain"), "plain");
}
}