Skip to main content

tuika/term/
hyperlink.rs

1//! OSC 8 terminal hyperlinks.
2//!
3//! Like [`crate::term::progress`] (OSC 9;4), this is an out-of-band terminal
4//! capability that ratatui's cell buffer cannot carry: a `Cell` holds one
5//! grapheme + style, with nowhere to attach a link target, and embedding the
6//! escape in a cell's symbol breaks width accounting. So hyperlinks are emitted
7//! by writing styled spans *directly* to the terminal, wrapping link runs in the
8//! OSC 8 sequence:
9//!
10//! ```text
11//! ESC ] 8 ; ; <url> ST   <visible text>   ESC ] 8 ; ; ST
12//! ```
13//!
14//! where `ST` is the string terminator (`ESC \`). Terminals that support OSC 8
15//! (Ghostty, iTerm2, WezTerm, Kitty, recent VTE) make the run clickable; others
16//! ignore the unknown OSC and render the text unchanged.
17//!
18//! [`encode`] is the pure encoder (validated + sanitized, unit-testable with no
19//! I/O). [`write_line`] serializes a ratatui [`Line`] — colors, common
20//! modifiers, and OSC 8 links — to any [`Write`] sink, so a host can push a
21//! transcript line to scrollback with real hyperlinks instead of going through
22//! the cell buffer.
23
24use 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
39/// String terminator for an OSC sequence: `ESC \`.
40const ST: &str = "\x1b\\";
41
42/// Which URL schemes tuika turns into OSC 8 hyperlinks.
43///
44/// The default ([`LinkPolicy::WEB`]) is deliberately conservative — only
45/// `http(s)` — because an OSC 8 target a terminal will act on is a capability
46/// surface: `file:`, `tel:`, and custom app schemes can do more than open a web
47/// page, and mapping arbitrary schemes to handlers is where the real risk
48/// lives. Hosts opt into anything beyond `http(s)` explicitly, so the safe set
49/// is the one you get by default.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct LinkPolicy {
52    web: bool,
53    mailto: bool,
54}
55
56impl LinkPolicy {
57    /// Link nothing — makes [`HyperlinkBackend`] a pure pass-through.
58    pub const NONE: Self = Self {
59        web: false,
60        mailto: false,
61    };
62    /// The conservative default: `http://` and `https://` only.
63    pub const WEB: Self = Self {
64        web: true,
65        mailto: false,
66    };
67
68    /// Also treat `mailto:` addresses as links. The target is still stripped of
69    /// control characters (the same ESC/BEL breakout defense as web URLs), and
70    /// its query component (`?subject=`, `?cc=`, `?bcc=`, `?body=`, …) is
71    /// dropped so a click can't be steered into pre-filled headers — a clickable
72    /// `mailto:` only ever opens a compose window to the bare address.
73    pub const fn with_mailto(mut self) -> Self {
74        self.mailto = true;
75        self
76    }
77
78    /// Whether the policy links anything at all (`false` ⇒ pass-through).
79    pub const fn links_any(self) -> bool {
80        self.web || self.mailto
81    }
82
83    /// Whether `url` is a valid target under this policy.
84    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
95/// Web scheme prefixes, longest first so `https://` wins over `http://`.
96const WEB_PREFIXES: [&str; 2] = ["https://", "http://"];
97/// The `mailto:` scheme prefix.
98const MAILTO_PREFIX: &str = "mailto:";
99
100/// Wrap `text` in an OSC 8 hyperlink to `url` under `policy`, or return `text`
101/// unchanged when `url` is not a valid, safe target for that policy. Pure and
102/// allocation-only — no I/O.
103pub 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
110/// [`encode_with`] under the default ([`LinkPolicy::WEB`]) policy: wrap `text` in a
111/// link to `url` when `url` is a safe `http(s)` URL, else return `text`.
112pub fn encode(url: &str, text: &str) -> String {
113    encode_with(url, text, LinkPolicy::default())
114}
115
116/// Whether `s` is a bare `http(s)://` URL with no interior whitespace — the
117/// shape a host can hand to [`write_line`] as a link run.
118pub fn is_web_url(s: &str) -> bool {
119    (s.starts_with("http://") || s.starts_with("https://")) && !s.chars().any(char::is_whitespace)
120}
121
122/// Whether `s` is a bare URL (no interior whitespace) whose scheme `policy`
123/// links. Generalizes [`is_web_url`] to the enabled scheme set — the shape a
124/// span must have for [`write_line_with`] to wrap it.
125fn 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
133/// Strip control characters — including the `ESC`/`BEL` that could terminate the
134/// OSC early and let a crafted target break out of the sequence — plus `DEL`.
135fn strip_controls(s: &str) -> String {
136    s.chars()
137        .filter(|&c| !c.is_control() && c != '\u{7f}')
138        .collect()
139}
140
141/// Validate `url` against `policy` and neutralize anything that could break out
142/// of the OSC 8 sequence, returning the safe target or `None`.
143///
144/// Every accepted scheme has its control characters removed. `mailto:`
145/// additionally has its query (`?…`) dropped before cleaning, so header
146/// parameters can't ride along — see [`LinkPolicy::with_mailto`].
147fn 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        // Drop the query before cleaning so `?cc=…`/`?body=…` never reach the
154        // terminal, then strip control chars like any other target.
155        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/// A hyperlink run in a rendered buffer: columns `[start_col, end_col)` on
163/// `line` (0-based within the rendered lines, not screen coordinates) point at
164/// `url`. Produced by markdown when a `[label](url)` (or bare URL) survives
165/// wrapping; applied with [`apply_buffer_links`].
166#[derive(Clone, Debug, PartialEq, Eq)]
167pub struct BufferLink {
168    /// Row index within the rendered line list (0-based).
169    pub line: u16,
170    /// First column of the link run, relative to the line's left edge.
171    pub start_col: u16,
172    /// Exclusive end column of the link run.
173    pub end_col: u16,
174    /// Link target (not necessarily equal to the visible label).
175    pub url: String,
176}
177
178/// OSC 8 opener prefix written into a cell symbol: `ESC ] 8 ; ;`.
179const OSC8_OPEN: &str = "\x1b]8;;";
180
181/// Embed OSC 8 hyperlinks for each [`BufferLink`] into `buf`.
182///
183/// `origin` is the top-left of the area the linked lines were painted into
184/// (`origin.x` + `start_col`, `origin.y` + `line`). Runs whose scheme `policy`
185/// rejects are skipped. Boundary cells carry the opener/closer with
186/// [`CellDiffOption::ForcedWidth`] so the escapes cost no columns — the same
187/// technique as a post-render bare-URL pass, but driven by explicit targets so
188/// a markdown `[label](url)` stays clickable even when the label is not the URL.
189///
190/// Idempotent per cell: a boundary that already holds an OSC 8 marker is left
191/// alone, so a host can re-apply after a partial redraw without stacking
192/// escapes.
193pub 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
220/// Wrap `cell`'s symbol in an OSC 8 open (`head`) or close (`!head`) sequence,
221/// forcing width 1. No-ops when the cell already carries that marker.
222fn 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    // Closer: `glyph + ESC ] 8 ; ; ST`. Skip when a closer (or a combined
233    // open+close on a one-cell run) is already present.
234    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
241/// Visible grapheme of a cell symbol with any OSC 8 wrapper stripped — used when
242/// reconstructing a row for bare-URL scanning so an already-linked cell is not
243/// re-wrapped and so Ctrl+click hit-testing sees the label, not the escape.
244fn visible_symbol(symbol: &str) -> String {
245    strip_osc8(symbol)
246}
247
248/// Remove OSC 8 open/close sequences from `s`, leaving the visible text.
249fn 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        // ESC ] 8 ; ; … ST  (ST = ESC \)
255        if bytes[i..].starts_with(b"\x1b]8;;") {
256            i += 5; // skip ESC ] 8 ; ;
257            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        // Copy one UTF-8 scalar.
267        let ch = s[i..].chars().next().unwrap();
268        out.push(ch);
269        i += ch.len_utf8();
270    }
271    out
272}
273
274/// Extract an OSC 8 target from a cell symbol, if the opener is present.
275fn 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
282/// Return the visible HTTP(S) URL under a Ctrl+left-button release.
283///
284/// Resolution order:
285/// 1. An OSC 8 target already embedded in the cell run under the pointer
286///    (markdown `[label](url)` after [`apply_buffer_links`]).
287/// 2. A bare `http(s)://…` run visible in the row (HyperlinkBackend / plain
288///    transcript URLs).
289///
290/// Opening the URL remains the host's responsibility.
291pub 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
295/// [`ctrl_click_url`] with an explicit [`LinkPolicy`] for the bare-URL fallback.
296pub 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    // Prefer an OSC 8 target covering this column — labeled markdown links live
314    // here, and the visible text may not be a URL at all.
315    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
336/// Walk left from `(col, row)` for an OSC 8 opener and right for its closer;
337/// return the target when `col` sits inside that run.
338fn 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    // Confirm a closer exists at or after `col` (or the open cell itself closes
349    // a single-cell link), and that no later opener sits between open and col.
350    for x in open_at..=col {
351        if x > open_at && osc8_target_in(buffer[(x, row)].symbol()).is_some() {
352            // A newer opener superseded the one we found — shouldn't happen for
353            // well-formed runs; treat as not inside the original link.
354            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        // A new opener before a closer means our run ended without covering col.
365        if x > col && osc8_target_in(sym).is_some() {
366            return None;
367        }
368    }
369    closed.then_some(url)
370}
371
372/// Byte ranges of every linkable URL in `s` under `policy`, left to right,
373/// non-overlapping. Each match runs to the next whitespace with trailing
374/// sentence punctuation trimmed, matching how the host styles links; a
375/// `mailto:` match also stops at its query `?` so the pre-fill params are
376/// neither shown nor linked.
377pub(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    // (prefix, is_mailto) for each enabled scheme; web prefixes longest-first so
384    // ties at the same offset resolve to `https://` over `http://`.
385    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        // Leftmost occurrence of any enabled prefix.
397        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            // Scheme with no target (e.g. a bare "http://"). Skip past just the
413            // prefix so a later scheme in the same string is still found.
414            offset = start + prefix.len();
415            continue;
416        }
417        ranges.push((start, start + len));
418        offset = start + len;
419    }
420    ranges
421}
422
423/// Serialize a ratatui [`Line`] to `out` with SGR styling and OSC 8 links, then
424/// reset styling. A span whose visible text is a bare web URL (see
425/// [`is_web_url`]) is emitted as a hyperlink to itself; every other span is
426/// printed as plain styled text. Does not emit a trailing newline — the caller
427/// controls line breaks.
428pub fn write_line(out: &mut impl Write, line: &Line<'_>) -> io::Result<()> {
429    write_line_with(out, line, LinkPolicy::default())
430}
431
432/// [`write_line`] with an explicit [`LinkPolicy`], so a host can decide which
433/// schemes (e.g. `mailto:`) become hyperlinks when it pushes a line to
434/// scrollback.
435pub 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    // Reset after each span so styles never bleed into the next one.
457    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
484/// Map a ratatui color to the crossterm equivalent. `Rgb`/`Indexed` (what the
485/// host's transcript actually uses) map exactly; the named ANSI colors map to
486/// their crossterm counterparts.
487fn 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
511/// A ratatui [`Backend`] that wraps [`CrosstermBackend`] and makes `http(s)`
512/// URLs in rendered output real OSC 8 hyperlinks.
513///
514/// This is the only place OSC 8 can be emitted while staying inside ratatui's
515/// model: every method delegates to the inner [`CrosstermBackend`] — so cursor,
516/// scroll-region, and `insert_before` bookkeeping stay consistent — except
517/// [`draw`](Backend::draw), which scans each contiguous run of cells for URLs
518/// and wraps just those cells in the OSC 8 sequence. Non-URL text is forwarded
519/// untouched. When the policy links nothing ([`LinkPolicy::NONE`]) it is a pure
520/// pass-through with no scanning, so a host can gate the feature at zero cost.
521pub struct HyperlinkBackend<W: Write> {
522    inner: CrosstermBackend<W>,
523    policy: LinkPolicy,
524}
525
526impl<W: Write> HyperlinkBackend<W> {
527    /// Wrap `writer`. `enabled` turns OSC 8 emission on under the default
528    /// ([`LinkPolicy::WEB`]) policy; when false, `draw` forwards straight to the
529    /// inner backend. Use [`with_policy`](Self::with_policy) to link other
530    /// schemes (e.g. `mailto:`).
531    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    /// Wrap `writer` with an explicit [`LinkPolicy`], so the host decides which
541    /// schemes become hyperlinks. [`LinkPolicy::NONE`] is a pure pass-through.
542    pub fn with_policy(writer: W, policy: LinkPolicy) -> Self {
543        Self {
544            inner: CrosstermBackend::new(writer),
545            policy,
546        }
547    }
548
549    /// Emit one maximal contiguous run of cells, wrapping any URL sub-runs in
550    /// OSC 8. Reuses the inner backend's `draw` for all SGR/cursor logic so we
551    /// never reimplement styling.
552    fn emit_run(&mut self, run: &[(u16, u16, &Cell)]) -> io::Result<()> {
553        // Reconstruct the run's visible text (OSC 8 wrappers stripped so an
554        // already-linked markdown label is not mistaken for a bare URL) and
555        // remember where each cell starts so a URL byte-range maps back to a
556        // cell index range.
557        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            // URL boundaries align to cell boundaries (a cell is one grapheme),
572            // so partition_point lands exactly on the first cell at/after each
573            // byte offset.
574            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                // Skip re-wrapping a sub-run that [`apply_buffer_links`] already
582                // marked — nesting OSC 8 breaks click targets.
583                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                            // CrosstermBackend implements Write, so raw OSC 8 bytes go
590                            // straight through to its inner writer.
591                            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            // A run is cells on the same row with strictly increasing adjacent
632            // columns — the shape ratatui produces for a freshly drawn line.
633            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    // Scrolling regions carry no cell content, so there is nothing to wrap in an
676    // OSC 8 target; forward them verbatim. These exist only so this backend
677    // still implements `Backend` when the `scrolling-regions` feature is on —
678    // which a host can cause from its own `ratatui` dependency.
679    #[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        // Non-web schemes are left as plain text.
710        assert_eq!(encode("mailto:a@b.com", "mail"), "mail");
711        assert_eq!(encode("ftp://host/x", "f"), "f");
712        // A URL trying to smuggle an ESC (which could terminate the OSC early
713        // and break out) has the control byte stripped; the link target keeps
714        // no raw ESC, so it cannot escape the sequence.
715        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        // The URL span is wrapped in OSC 8 to itself; plain text is untouched.
741        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        // Underline attribute (SGR 4) is present, the link is wrapped, and the
758        // line ends reset. Truecolor SGR form varies by crossterm/TERM, so we
759        // only require that *some* foreground command preceded the text.
760        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        // Default (web-only) policy neither encodes nor finds `mailto:`.
824        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        // Found in running text, trailing punctuation trimmed.
836        assert_eq!(find_links("write mailto:a@b.com.", policy), vec![(6, 20)]);
837        // Web still works alongside mailto.
838        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        // The `?cc=…&body=…` header params are dropped from both the linked
848        // range and the sanitized target.
849        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    /// A `Write` whose buffer we can inspect after the backend consumes it.
881    #[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    /// Render a row of single-char cells through a backend built with `policy`
895    /// and return the emitted bytes.
896    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    /// Render a row through the `enabled`→default-policy mapping of
917    /// [`HyperlinkBackend::new`].
918    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        // The link target appears exactly once as an OSC 8 target (not around
936        // the surrounding words).
937        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        // Text is still rendered.
948        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        // Default (web) policy leaves the mailto as plain text.
961        assert!(!draw_row_with(row, LinkPolicy::default()).contains("\x1b]8;;"));
962        // With mailto opted in, the address run is wrapped in OSC 8.
963        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        // Reproduction: a markdown `[label](url)` paints only the label. Without
977        // carrying the destination into the buffer, Ctrl+click / Ghostty OSC 8
978        // has nothing to open. apply_buffer_links embeds the target.
979        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        // "docs" occupies columns 6..10 (origin-relative 4..8).
985        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}