1use std::io::{self, Write};
25use std::num::NonZeroU16;
26
27use crossterm::queue;
28use crossterm::style::{
29 Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
30 SetForegroundColor,
31};
32use ratatui_core::backend::{Backend, ClearType, WindowSize};
33use ratatui_core::buffer::{Buffer, Cell, CellDiffOption};
34use ratatui_core::layout::{Position, Rect, Size};
35use ratatui_core::style::{Color, Modifier};
36use ratatui_core::text::{Line, Span};
37use ratatui_crossterm::CrosstermBackend;
38
39const ST: &str = "\x1b\\";
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct LinkPolicy {
52 web: bool,
53 mailto: bool,
54}
55
56impl LinkPolicy {
57 pub const NONE: Self = Self {
59 web: false,
60 mailto: false,
61 };
62 pub const WEB: Self = Self {
64 web: true,
65 mailto: false,
66 };
67
68 pub const fn with_mailto(mut self) -> Self {
74 self.mailto = true;
75 self
76 }
77
78 pub const fn links_any(self) -> bool {
80 self.web || self.mailto
81 }
82
83 pub fn allows(self, url: &str) -> bool {
85 sanitize_url(url, self).is_some()
86 }
87}
88
89impl Default for LinkPolicy {
90 fn default() -> Self {
91 Self::WEB
92 }
93}
94
95const WEB_PREFIXES: [&str; 2] = ["https://", "http://"];
97const MAILTO_PREFIX: &str = "mailto:";
99
100pub fn encode_with(url: &str, text: &str, policy: LinkPolicy) -> String {
104 match sanitize_url(url, policy) {
105 Some(url) => format!("\x1b]8;;{url}{ST}{text}\x1b]8;;{ST}"),
106 None => text.to_string(),
107 }
108}
109
110pub fn encode(url: &str, text: &str) -> String {
113 encode_with(url, text, LinkPolicy::default())
114}
115
116pub fn is_web_url(s: &str) -> bool {
119 (s.starts_with("http://") || s.starts_with("https://")) && !s.chars().any(char::is_whitespace)
120}
121
122fn is_linkable(s: &str, policy: LinkPolicy) -> bool {
126 if s.chars().any(char::is_whitespace) {
127 return false;
128 }
129 (policy.web && WEB_PREFIXES.iter().any(|p| s.starts_with(p)))
130 || (policy.mailto && s.starts_with(MAILTO_PREFIX))
131}
132
133fn strip_controls(s: &str) -> String {
136 s.chars()
137 .filter(|&c| !c.is_control() && c != '\u{7f}')
138 .collect()
139}
140
141fn sanitize_url(url: &str, policy: LinkPolicy) -> Option<String> {
148 if policy.web && WEB_PREFIXES.iter().any(|p| url.starts_with(p)) {
149 let cleaned = strip_controls(url);
150 return (cleaned.len() >= "http://".len()).then_some(cleaned);
151 }
152 if policy.mailto && url.starts_with(MAILTO_PREFIX) {
153 let addr = url.split('?').next().unwrap_or(url);
156 let cleaned = strip_controls(addr);
157 return (cleaned.len() > MAILTO_PREFIX.len()).then_some(cleaned);
158 }
159 None
160}
161
162#[derive(Clone, Debug, PartialEq, Eq)]
167pub struct BufferLink {
168 pub line: u16,
170 pub start_col: u16,
172 pub end_col: u16,
174 pub url: String,
176}
177
178const OSC8_OPEN: &str = "\x1b]8;;";
180
181pub fn apply_buffer_links(
194 buf: &mut Buffer,
195 origin: Position,
196 links: &[BufferLink],
197 policy: LinkPolicy,
198) {
199 if !policy.links_any() {
200 return;
201 }
202 for link in links {
203 let Some(url) = sanitize_url(&link.url, policy) else {
204 continue;
205 };
206 if link.end_col <= link.start_col {
207 continue;
208 }
209 let y = origin.y.saturating_add(link.line);
210 let xs = origin.x.saturating_add(link.start_col);
211 let xe = origin.x.saturating_add(link.end_col.saturating_sub(1));
212 if y >= buf.area.bottom() || xs >= buf.area.right() || xe >= buf.area.right() || xe < xs {
213 continue;
214 }
215 wrap_cell_osc8(&mut buf[(xs, y)], &url, true);
216 wrap_cell_osc8(&mut buf[(xe, y)], &url, false);
217 }
218}
219
220fn wrap_cell_osc8(cell: &mut Cell, url: &str, head: bool) {
223 let sym = cell.symbol();
224 if head {
225 if sym.contains(OSC8_OPEN) {
226 return;
227 }
228 cell.set_symbol(&format!("{OSC8_OPEN}{url}{ST}{sym}"))
229 .set_diff_option(CellDiffOption::ForcedWidth(NonZeroU16::new(1).unwrap()));
230 return;
231 }
232 if sym.contains("\x1b]8;;\x1b\\") {
235 return;
236 }
237 cell.set_symbol(&format!("{sym}{OSC8_OPEN}{ST}"))
238 .set_diff_option(CellDiffOption::ForcedWidth(NonZeroU16::new(1).unwrap()));
239}
240
241fn visible_symbol(symbol: &str) -> String {
245 strip_osc8(symbol)
246}
247
248fn strip_osc8(s: &str) -> String {
250 let mut out = String::with_capacity(s.len());
251 let bytes = s.as_bytes();
252 let mut i = 0;
253 while i < bytes.len() {
254 if bytes[i..].starts_with(b"\x1b]8;;") {
256 i += 5; while i + 1 < bytes.len() {
258 if bytes[i] == 0x1b && bytes[i + 1] == b'\\' {
259 i += 2;
260 break;
261 }
262 i += 1;
263 }
264 continue;
265 }
266 let ch = s[i..].chars().next().unwrap();
268 out.push(ch);
269 i += ch.len_utf8();
270 }
271 out
272}
273
274fn osc8_target_in(symbol: &str) -> Option<String> {
276 let rest = symbol.strip_prefix(OSC8_OPEN)?;
277 let end = rest.find(ST)?;
278 let url = &rest[..end];
279 (!url.is_empty()).then(|| url.to_string())
280}
281
282pub fn ctrl_click_url(event: &crate::Mouse, buffer: &Buffer, area: Rect) -> Option<String> {
292 ctrl_click_url_with(event, buffer, area, LinkPolicy::default())
293}
294
295pub fn ctrl_click_url_with(
297 event: &crate::Mouse,
298 buffer: &Buffer,
299 area: Rect,
300 policy: LinkPolicy,
301) -> Option<String> {
302 if event.kind != crate::MouseKind::Up(crate::MouseButton::Left)
303 || !event.ctrl
304 || event.shift
305 || event.alt
306 || event.row < area.y
307 || event.row >= area.bottom()
308 || event.column < area.x
309 || event.column >= area.right()
310 {
311 return None;
312 }
313 if let Some(url) = osc8_url_at(buffer, area, event.column, event.row)
316 && sanitize_url(&url, policy).is_some()
317 {
318 return Some(url);
319 }
320 let mut row = String::new();
321 let mut clicked_bytes = 0..0;
322 for column in area.x..area.right() {
323 let start = row.len();
324 let visible = visible_symbol(buffer[(column, event.row)].symbol());
325 row.push_str(&visible);
326 if column == event.column {
327 clicked_bytes = start..row.len();
328 }
329 }
330 find_links(&row, policy)
331 .into_iter()
332 .find(|(start, end)| *start < clicked_bytes.end && clicked_bytes.start < *end)
333 .map(|(start, end)| row[start..end].to_string())
334}
335
336fn osc8_url_at(buffer: &Buffer, area: Rect, col: u16, row: u16) -> Option<String> {
339 let mut url = None;
340 let mut open_at = None;
341 for x in area.x..=col {
342 if let Some(u) = osc8_target_in(buffer[(x, row)].symbol()) {
343 url = Some(u);
344 open_at = Some(x);
345 }
346 }
347 let (url, open_at) = (url?, open_at?);
348 for x in open_at..=col {
351 if x > open_at && osc8_target_in(buffer[(x, row)].symbol()).is_some() {
352 return None;
355 }
356 }
357 let mut closed = false;
358 for x in col..area.right() {
359 let sym = buffer[(x, row)].symbol();
360 if sym.contains("\x1b]8;;\x1b\\") || sym.ends_with("\x1b]8;;\x1b\\") {
361 closed = true;
362 break;
363 }
364 if x > col && osc8_target_in(sym).is_some() {
366 return None;
367 }
368 }
369 closed.then_some(url)
370}
371
372pub(crate) fn find_links(s: &str, policy: LinkPolicy) -> Vec<(usize, usize)> {
378 const TRAILING: &[char] = &['.', ',', ';', ':', '!', '?', ')', ']', '}', '\'', '"'];
379 let mut ranges = Vec::new();
380 if !policy.links_any() {
381 return ranges;
382 }
383 let mut prefixes: Vec<(&str, bool)> = Vec::new();
386 if policy.web {
387 prefixes.extend(WEB_PREFIXES.iter().map(|&p| (p, false)));
388 }
389 if policy.mailto {
390 prefixes.push((MAILTO_PREFIX, true));
391 }
392
393 let mut offset = 0;
394 while offset < s.len() {
395 let rest = &s[offset..];
396 let Some((rel, prefix, is_mailto)) = prefixes
398 .iter()
399 .filter_map(|&(p, m)| rest.find(p).map(|i| (i, p, m)))
400 .min_by_key(|&(i, ..)| i)
401 else {
402 break;
403 };
404 let start = offset + rel;
405 let tail = &s[start..];
406 let mut raw_end = tail.find(char::is_whitespace).unwrap_or(tail.len());
407 if is_mailto && let Some(q) = tail[..raw_end].find('?') {
408 raw_end = q;
409 }
410 let len = tail[..raw_end].trim_end_matches(TRAILING).len();
411 if len <= prefix.len() {
412 offset = start + prefix.len();
415 continue;
416 }
417 ranges.push((start, start + len));
418 offset = start + len;
419 }
420 ranges
421}
422
423pub fn write_line(out: &mut impl Write, line: &Line<'_>) -> io::Result<()> {
429 write_line_with(out, line, LinkPolicy::default())
430}
431
432pub fn write_line_with(
436 out: &mut impl Write,
437 line: &Line<'_>,
438 policy: LinkPolicy,
439) -> io::Result<()> {
440 for span in &line.spans {
441 write_span(out, span, policy)?;
442 }
443 queue!(out, ResetColor, SetAttribute(Attribute::Reset))?;
444 Ok(())
445}
446
447fn write_span(out: &mut impl Write, span: &Span<'_>, policy: LinkPolicy) -> io::Result<()> {
448 apply_style(out, span)?;
449 let content = span.content.as_ref();
450 let is_link = content.trim() == content && is_linkable(content, policy);
451 if is_link {
452 queue!(out, Print(encode_with(content, content, policy)))?;
453 } else {
454 queue!(out, Print(content))?;
455 }
456 queue!(out, ResetColor, SetAttribute(Attribute::Reset))?;
458 Ok(())
459}
460
461fn apply_style(out: &mut impl Write, span: &Span<'_>) -> io::Result<()> {
462 let style = span.style;
463 if let Some(fg) = style.fg {
464 queue!(out, SetForegroundColor(to_ct_color(fg)))?;
465 }
466 if let Some(bg) = style.bg {
467 queue!(out, SetBackgroundColor(to_ct_color(bg)))?;
468 }
469 for (modifier, attribute) in [
470 (Modifier::BOLD, Attribute::Bold),
471 (Modifier::DIM, Attribute::Dim),
472 (Modifier::ITALIC, Attribute::Italic),
473 (Modifier::UNDERLINED, Attribute::Underlined),
474 (Modifier::CROSSED_OUT, Attribute::CrossedOut),
475 (Modifier::REVERSED, Attribute::Reverse),
476 ] {
477 if style.add_modifier.contains(modifier) {
478 queue!(out, SetAttribute(attribute))?;
479 }
480 }
481 Ok(())
482}
483
484fn to_ct_color(color: Color) -> CtColor {
488 match color {
489 Color::Reset => CtColor::Reset,
490 Color::Black => CtColor::Black,
491 Color::Red => CtColor::DarkRed,
492 Color::Green => CtColor::DarkGreen,
493 Color::Yellow => CtColor::DarkYellow,
494 Color::Blue => CtColor::DarkBlue,
495 Color::Magenta => CtColor::DarkMagenta,
496 Color::Cyan => CtColor::DarkCyan,
497 Color::Gray => CtColor::Grey,
498 Color::DarkGray => CtColor::DarkGrey,
499 Color::LightRed => CtColor::Red,
500 Color::LightGreen => CtColor::Green,
501 Color::LightYellow => CtColor::Yellow,
502 Color::LightBlue => CtColor::Blue,
503 Color::LightMagenta => CtColor::Magenta,
504 Color::LightCyan => CtColor::Cyan,
505 Color::White => CtColor::White,
506 Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
507 Color::Indexed(i) => CtColor::AnsiValue(i),
508 }
509}
510
511pub struct HyperlinkBackend<W: Write> {
522 inner: CrosstermBackend<W>,
523 policy: LinkPolicy,
524}
525
526impl<W: Write> HyperlinkBackend<W> {
527 pub fn new(writer: W, enabled: bool) -> Self {
532 let policy = if enabled {
533 LinkPolicy::default()
534 } else {
535 LinkPolicy::NONE
536 };
537 Self::with_policy(writer, policy)
538 }
539
540 pub fn with_policy(writer: W, policy: LinkPolicy) -> Self {
543 Self {
544 inner: CrosstermBackend::new(writer),
545 policy,
546 }
547 }
548
549 fn emit_run(&mut self, run: &[(u16, u16, &Cell)]) -> io::Result<()> {
553 let mut text = String::new();
558 let mut cell_starts = Vec::with_capacity(run.len());
559 for (_, _, cell) in run {
560 cell_starts.push(text.len());
561 text.push_str(&visible_symbol(cell.symbol()));
562 }
563
564 let urls = find_links(&text, self.policy);
565 if urls.is_empty() {
566 return self.inner.draw(run.iter().copied());
567 }
568
569 let mut cursor = 0usize;
570 for (byte_start, byte_end) in urls {
571 let start_cell = cell_starts.partition_point(|&b| b < byte_start);
575 let end_cell = cell_starts.partition_point(|&b| b < byte_end);
576 if cursor < start_cell {
577 self.inner.draw(run[cursor..start_cell].iter().copied())?;
578 }
579 if start_cell < end_cell {
580 let sub = &run[start_cell..end_cell];
581 let already = sub.iter().any(|(_, _, c)| c.symbol().contains(OSC8_OPEN));
584 if already {
585 self.inner.draw(sub.iter().copied())?;
586 } else {
587 match sanitize_url(&text[byte_start..byte_end], self.policy) {
588 Some(url) => {
589 write!(self.inner, "\x1b]8;;{url}{ST}")?;
592 self.inner.draw(sub.iter().copied())?;
593 write!(self.inner, "\x1b]8;;{ST}")?;
594 }
595 None => self.inner.draw(sub.iter().copied())?,
596 }
597 }
598 }
599 cursor = end_cell.max(cursor);
600 }
601 if cursor < run.len() {
602 self.inner.draw(run[cursor..].iter().copied())?;
603 }
604 Ok(())
605 }
606}
607
608impl<W: Write> Write for HyperlinkBackend<W> {
609 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
610 self.inner.write(buf)
611 }
612 fn flush(&mut self) -> io::Result<()> {
613 Write::flush(&mut self.inner)
614 }
615}
616
617impl<W: Write> Backend for HyperlinkBackend<W> {
618 type Error = io::Error;
619
620 fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
621 where
622 I: Iterator<Item = (u16, u16, &'a Cell)>,
623 {
624 if !self.policy.links_any() {
625 return self.inner.draw(content);
626 }
627 let cells: Vec<(u16, u16, &Cell)> = content.collect();
628 let mut i = 0;
629 while i < cells.len() {
630 let mut j = i + 1;
631 while j < cells.len()
634 && cells[j].1 == cells[j - 1].1
635 && cells[j].0 == cells[j - 1].0 + 1
636 {
637 j += 1;
638 }
639 self.emit_run(&cells[i..j])?;
640 i = j;
641 }
642 Ok(())
643 }
644
645 fn append_lines(&mut self, n: u16) -> io::Result<()> {
646 self.inner.append_lines(n)
647 }
648 fn hide_cursor(&mut self) -> io::Result<()> {
649 self.inner.hide_cursor()
650 }
651 fn show_cursor(&mut self) -> io::Result<()> {
652 self.inner.show_cursor()
653 }
654 fn get_cursor_position(&mut self) -> io::Result<Position> {
655 self.inner.get_cursor_position()
656 }
657 fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
658 self.inner.set_cursor_position(position)
659 }
660 fn clear(&mut self) -> io::Result<()> {
661 self.inner.clear()
662 }
663 fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
664 self.inner.clear_region(clear_type)
665 }
666 fn size(&self) -> io::Result<Size> {
667 self.inner.size()
668 }
669 fn window_size(&mut self) -> io::Result<WindowSize> {
670 self.inner.window_size()
671 }
672 fn flush(&mut self) -> io::Result<()> {
673 Backend::flush(&mut self.inner)
674 }
675 #[cfg(feature = "scrolling-regions")]
680 fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> io::Result<()> {
681 self.inner.scroll_region_up(region, lines)
682 }
683 #[cfg(feature = "scrolling-regions")]
684 fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> io::Result<()> {
685 self.inner.scroll_region_down(region, lines)
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 fn bytes(line: &Line<'_>) -> String {
694 let mut out: Vec<u8> = Vec::new();
695 write_line(&mut out, line).expect("write");
696 String::from_utf8(out).expect("utf8")
697 }
698
699 #[test]
700 fn osc8_wraps_valid_web_urls() {
701 assert_eq!(
702 encode("https://example.com", "example"),
703 "\x1b]8;;https://example.com\x1b\\example\x1b]8;;\x1b\\"
704 );
705 }
706
707 #[test]
708 fn osc8_passes_through_non_web_or_unsafe_urls() {
709 assert_eq!(encode("mailto:a@b.com", "mail"), "mail");
711 assert_eq!(encode("ftp://host/x", "f"), "f");
712 let sneaky = "https://evil\x1b\\.com";
716 let encoded = encode(sneaky, "x");
717 assert!(
718 !encoded.contains("evil\x1b"),
719 "raw escape must be stripped from the target: {encoded:?}"
720 );
721 assert!(encoded.starts_with("\x1b]8;;https://evil"));
722 }
723
724 #[test]
725 fn is_web_url_requires_scheme_and_no_whitespace() {
726 assert!(is_web_url("https://a.dev/x?y=1"));
727 assert!(is_web_url("http://a.dev"));
728 assert!(!is_web_url("a.dev"));
729 assert!(!is_web_url("https://a.dev x"));
730 }
731
732 #[test]
733 fn write_line_hyperlinks_url_spans_only() {
734 let line = Line::from(vec![
735 Span::raw("see "),
736 Span::raw("https://rust-lang.org"),
737 Span::raw(" now"),
738 ]);
739 let out = bytes(&line);
740 assert!(
742 out.contains("\x1b]8;;https://rust-lang.org\x1b\\https://rust-lang.org\x1b]8;;\x1b\\")
743 );
744 assert!(out.contains("see "));
745 assert!(out.contains(" now"));
746 }
747
748 #[test]
749 fn write_line_emits_color_and_underline_then_resets() {
750 let line = Line::from(Span::styled(
751 "https://a.dev",
752 ratatui_core::style::Style::default()
753 .fg(Color::Rgb(45, 91, 158))
754 .add_modifier(Modifier::UNDERLINED),
755 ));
756 let out = bytes(&line);
757 assert!(out.contains("\x1b[4m"), "underline SGR expected: {out:?}");
761 assert!(
762 out.contains("\x1b]8;;https://a.dev\x1b\\"),
763 "OSC 8 wrap expected: {out:?}"
764 );
765 assert!(out.trim_end().ends_with("\x1b[0m") || out.contains("\x1b[0m"));
766 }
767
768 #[test]
769 fn write_line_plain_text_has_no_osc8() {
770 let line = Line::from(Span::raw("no links here"));
771 let out = bytes(&line);
772 assert!(!out.contains("\x1b]8;;"));
773 assert!(out.contains("no links here"));
774 }
775
776 #[test]
777 fn ctrl_click_returns_visible_url_under_pointer() {
778 use crate::{Mouse, MouseButton, MouseKind};
779 use ratatui_core::{buffer::Buffer, layout::Rect, style::Style};
780 let area = Rect::new(3, 2, 40, 1);
781 let mut buffer = Buffer::empty(Rect::new(0, 0, 50, 5));
782 buffer.set_string(
783 area.x,
784 area.y,
785 "see https://example.com/docs now",
786 Style::default(),
787 );
788 let mut event = Mouse::at(MouseKind::Up(MouseButton::Left), 15, area.y);
789 event.ctrl = true;
790 assert_eq!(
791 ctrl_click_url(&event, &buffer, area).as_deref(),
792 Some("https://example.com/docs")
793 );
794 }
795
796 #[test]
797 fn ctrl_click_ignores_plain_clicks_and_non_url_text() {
798 use crate::{Mouse, MouseButton, MouseKind};
799 use ratatui_core::{buffer::Buffer, layout::Rect, style::Style};
800 let area = Rect::new(0, 0, 30, 1);
801 let mut buffer = Buffer::empty(area);
802 buffer.set_string(0, 0, "https://example.com plain", Style::default());
803 let plain = Mouse::at(MouseKind::Up(MouseButton::Left), 10, 0);
804 let mut text = Mouse::at(MouseKind::Up(MouseButton::Left), 23, 0);
805 text.ctrl = true;
806 assert_eq!(ctrl_click_url(&plain, &buffer, area), None);
807 assert_eq!(ctrl_click_url(&text, &buffer, area), None);
808 }
809
810 #[test]
811 fn find_web_urls_locates_and_trims() {
812 let web = LinkPolicy::default();
813 assert_eq!(find_links("see https://a.dev/x, ok", web), vec![(4, 19)]);
814 assert_eq!(
815 find_links("a http://x.io b https://y.io", web),
816 vec![(2, 13), (16, 28)]
817 );
818 assert!(find_links("no links", web).is_empty());
819 }
820
821 #[test]
822 fn mailto_is_off_under_default_policy() {
823 assert_eq!(encode("mailto:a@b.com", "mail"), "mail");
825 assert!(find_links("write mailto:a@b.com now", LinkPolicy::default()).is_empty());
826 }
827
828 #[test]
829 fn mailto_links_when_opted_in() {
830 let policy = LinkPolicy::WEB.with_mailto();
831 assert_eq!(
832 encode_with("mailto:a@b.com", "mail", policy),
833 "\x1b]8;;mailto:a@b.com\x1b\\mail\x1b]8;;\x1b\\"
834 );
835 assert_eq!(find_links("write mailto:a@b.com.", policy), vec![(6, 20)]);
837 assert_eq!(
839 find_links("mailto:a@b.com then https://x.io", policy),
840 vec![(0, 14), (20, 32)]
841 );
842 }
843
844 #[test]
845 fn mailto_drops_query_to_block_header_injection() {
846 let policy = LinkPolicy::WEB.with_mailto();
847 assert_eq!(
850 find_links("mailto:a@b.com?cc=evil@x.com&body=hi", policy),
851 vec![(0, 14)]
852 );
853 let encoded = encode_with("mailto:a@b.com?cc=evil@x.com&body=hi", "m", policy);
854 assert_eq!(encoded, "\x1b]8;;mailto:a@b.com\x1b\\m\x1b]8;;\x1b\\");
855 assert!(
856 !encoded.contains("cc="),
857 "query must not reach the OSC target"
858 );
859 }
860
861 #[test]
862 fn mailto_strips_control_bytes_from_target() {
863 let policy = LinkPolicy::WEB.with_mailto();
864 let sneaky = "mailto:a\x1b\\@b.com";
865 let encoded = encode_with(sneaky, "m", policy);
866 assert!(
867 !encoded.contains("a\x1b"),
868 "raw escape must be stripped: {encoded:?}"
869 );
870 assert!(encoded.starts_with("\x1b]8;;mailto:a"));
871 }
872
873 #[test]
874 fn mailto_without_address_is_not_a_link() {
875 let policy = LinkPolicy::WEB.with_mailto();
876 assert_eq!(encode_with("mailto:", "m", policy), "m");
877 assert!(find_links("bare mailto: here", policy).is_empty());
878 }
879
880 #[derive(Clone)]
882 struct SharedBuf(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
883
884 impl Write for SharedBuf {
885 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
886 self.0.borrow_mut().extend_from_slice(buf);
887 Ok(buf.len())
888 }
889 fn flush(&mut self) -> io::Result<()> {
890 Ok(())
891 }
892 }
893
894 fn draw_row_with(text: &str, policy: LinkPolicy) -> String {
897 use ratatui_core::buffer::Cell;
898 let cells: Vec<(u16, u16, Cell)> = text
899 .chars()
900 .enumerate()
901 .map(|(i, ch)| {
902 let mut cell = Cell::default();
903 cell.set_symbol(&ch.to_string());
904 (i as u16, 0u16, cell)
905 })
906 .collect();
907 let buf = SharedBuf(std::rc::Rc::new(std::cell::RefCell::new(Vec::new())));
908 let mut backend = HyperlinkBackend::with_policy(buf.clone(), policy);
909 backend
910 .draw(cells.iter().map(|(x, y, c)| (*x, *y, c)))
911 .expect("draw");
912 let bytes = buf.0.borrow().clone();
913 String::from_utf8(bytes).expect("utf8")
914 }
915
916 fn draw_row(text: &str, enabled: bool) -> String {
919 let policy = if enabled {
920 LinkPolicy::default()
921 } else {
922 LinkPolicy::NONE
923 };
924 draw_row_with(text, policy)
925 }
926
927 #[test]
928 fn backend_wraps_url_runs_in_osc8() {
929 let out = draw_row("see https://rust-lang.org now", true);
930 assert!(
931 out.contains("\x1b]8;;https://rust-lang.org\x1b\\"),
932 "URL run should open OSC 8: {out:?}"
933 );
934 assert!(out.contains("\x1b]8;;\x1b\\"), "URL run should close OSC 8");
935 assert_eq!(out.matches("\x1b]8;;https://").count(), 1);
938 }
939
940 #[test]
941 fn backend_disabled_emits_no_osc8() {
942 let out = draw_row("see https://rust-lang.org now", false);
943 assert!(
944 !out.contains("\x1b]8;;"),
945 "disabled backend must not link: {out:?}"
946 );
947 assert!(out.contains('h') && out.contains('s'));
949 }
950
951 #[test]
952 fn backend_plain_row_has_no_osc8() {
953 let out = draw_row("just some text", true);
954 assert!(!out.contains("\x1b]8;;"));
955 }
956
957 #[test]
958 fn backend_links_mailto_only_when_policy_allows() {
959 let row = "mail me at mailto:a@b.com today";
960 assert!(!draw_row_with(row, LinkPolicy::default()).contains("\x1b]8;;"));
962 let out = draw_row_with(row, LinkPolicy::WEB.with_mailto());
964 assert!(
965 out.contains("\x1b]8;;mailto:a@b.com\x1b\\"),
966 "mailto run should open OSC 8: {out:?}"
967 );
968 assert!(
969 out.contains("\x1b]8;;\x1b\\"),
970 "mailto run should close OSC 8"
971 );
972 }
973
974 #[test]
975 fn apply_buffer_links_makes_labeled_run_ctrl_clickable() {
976 use crate::{Mouse, MouseButton, MouseKind};
980 use ratatui_core::style::Style;
981 let area = Rect::new(2, 1, 20, 1);
982 let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 4));
983 buffer.set_string(area.x, area.y, "see docs here", Style::default());
984 let links = [BufferLink {
986 line: 0,
987 start_col: 4,
988 end_col: 8,
989 url: "https://example.com/docs".into(),
990 }];
991 apply_buffer_links(
992 &mut buffer,
993 Position {
994 x: area.x,
995 y: area.y,
996 },
997 &links,
998 LinkPolicy::WEB,
999 );
1000 let head = buffer[(area.x + 4, area.y)].symbol();
1001 assert!(
1002 head.starts_with("\x1b]8;;https://example.com/docs\x1b\\"),
1003 "opener cell: {head:?}"
1004 );
1005 let mut event = Mouse::at(MouseKind::Up(MouseButton::Left), area.x + 5, area.y);
1006 event.ctrl = true;
1007 assert_eq!(
1008 ctrl_click_url(&event, &buffer, area).as_deref(),
1009 Some("https://example.com/docs")
1010 );
1011 }
1012
1013 #[test]
1014 fn apply_buffer_links_respects_none_policy() {
1015 use ratatui_core::style::Style;
1016 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1017 buffer.set_string(0, 0, "docs", Style::default());
1018 apply_buffer_links(
1019 &mut buffer,
1020 Position { x: 0, y: 0 },
1021 &[BufferLink {
1022 line: 0,
1023 start_col: 0,
1024 end_col: 4,
1025 url: "https://example.com".into(),
1026 }],
1027 LinkPolicy::NONE,
1028 );
1029 assert_eq!(buffer[(0, 0)].symbol(), "d");
1030 assert!(!buffer[(0, 0)].symbol().contains("\x1b]8;;"));
1031 }
1032
1033 #[test]
1034 fn strip_osc8_leaves_visible_label() {
1035 assert_eq!(
1036 strip_osc8("\x1b]8;;https://x.dev\x1b\\hi\x1b]8;;\x1b\\"),
1037 "hi"
1038 );
1039 assert_eq!(strip_osc8("plain"), "plain");
1040 }
1041}