use std::io::{self, Write};
use crossterm::queue;
use crossterm::style::{
Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
SetForegroundColor,
};
use ratatui::backend::{Backend, ClearType, CrosstermBackend, WindowSize};
use ratatui::buffer::Cell;
use ratatui::layout::{Position, Size};
use ratatui::style::{Color, Modifier};
use ratatui::text::{Line, Span};
const ST: &str = "\x1b\\";
pub fn osc8(url: &str, text: &str) -> String {
match sanitize_web_url(url) {
Some(url) => format!("\x1b]8;;{url}{ST}{text}\x1b]8;;{ST}"),
None => text.to_string(),
}
}
pub fn is_web_url(s: &str) -> bool {
(s.starts_with("http://") || s.starts_with("https://")) && !s.chars().any(char::is_whitespace)
}
fn sanitize_web_url(url: &str) -> Option<String> {
if !(url.starts_with("http://") || url.starts_with("https://")) {
return None;
}
let cleaned: String = url
.chars()
.filter(|&c| !c.is_control() && c != '\u{7f}')
.collect();
(!cleaned.is_empty() && cleaned.len() >= "http://".len()).then_some(cleaned)
}
fn find_web_urls(s: &str) -> Vec<(usize, usize)> {
const TRAILING: &[char] = &['.', ',', ';', ':', '!', '?', ')', ']', '}', '\'', '"'];
let mut ranges = Vec::new();
let mut offset = 0;
while offset < s.len() {
let rest = &s[offset..];
let Some(rel) = rest
.find("https://")
.into_iter()
.chain(rest.find("http://"))
.min()
else {
break;
};
let start = offset + rel;
let tail = &s[start..];
let raw_end = tail.find(char::is_whitespace).unwrap_or(tail.len());
let len = tail[..raw_end].trim_end_matches(TRAILING).len();
if len == 0 {
break;
}
ranges.push((start, start + len));
offset = start + len;
}
ranges
}
pub fn write_line(out: &mut impl Write, line: &Line<'_>) -> io::Result<()> {
for span in &line.spans {
write_span(out, span)?;
}
queue!(out, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
fn write_span(out: &mut impl Write, span: &Span<'_>) -> io::Result<()> {
apply_style(out, span)?;
let content = span.content.as_ref();
let is_link = is_web_url(content.trim()) && content.trim() == content;
if is_link {
queue!(out, Print(osc8(content, content)))?;
} 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>,
enabled: bool,
}
impl<W: Write> HyperlinkBackend<W> {
pub fn new(writer: W, enabled: bool) -> Self {
Self {
inner: CrosstermBackend::new(writer),
enabled,
}
}
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(cell.symbol());
}
let urls = find_web_urls(&text);
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 {
match sanitize_web_url(&text[byte_start..byte_end]) {
Some(url) => {
write!(self.inner, "\x1b]8;;{url}{ST}")?;
self.inner.draw(run[start_cell..end_cell].iter().copied())?;
write!(self.inner, "\x1b]8;;{ST}")?;
}
None => self.inner.draw(run[start_cell..end_cell].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.enabled {
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::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[38;2;45;91;158m"),
"truecolor fg expected: {out:?}"
);
assert!(out.contains("\x1b]8;;https://a.dev\x1b\\"));
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 find_web_urls_locates_and_trims() {
assert_eq!(find_web_urls("see https://a.dev/x, ok"), vec![(4, 19)]);
assert_eq!(
find_web_urls("a http://x.io b https://y.io"),
vec![(2, 13), (16, 28)]
);
assert!(find_web_urls("no links").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(text: &str, enabled: bool) -> String {
use ratatui::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::new(buf.clone(), enabled);
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")
}
#[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;;"));
}
}