Skip to main content

tess/
render.rs

1use std::sync::Arc;
2use unicode_segmentation::UnicodeSegmentation;
3use unicode_width::UnicodeWidthStr;
4
5/// How the renderer treats escape sequences in input bytes.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum AnsiMode {
8    /// Pre-0.18 default. ESC renders as `^[` caret form; CSI bytes show as
9    /// `^[` + literal text. Used when `--no-color` is set.
10    #[default]
11    Strict,
12    /// Default at app level. SGR sequences update cell styles (zero columns
13    /// consumed); non-SGR CSI is parsed and discarded silently; OSC 8 wraps
14    /// hyperlinks.
15    Interpret,
16    /// `-r` / `--raw-control-chars`. Identical to Strict in the render
17    /// kernel — the writer handles raw passthrough.
18    Raw,
19}
20
21/// Per-source rendering state that persists across line renders. Carries the
22/// SGR style register and the current OSC 8 hyperlink so that an unclosed
23/// `\x1b[31m` on line N keeps line N+1 red until reset.
24#[derive(Debug, Default, Clone)]
25pub struct RenderState {
26    pub style: crate::ansi::Style,
27    pub hyperlink: Option<String>,
28    pub parse: crate::ansi::ParseState,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Cell {
33    Char {
34        ch: char,
35        width: u8,
36        style: crate::ansi::Style,
37        hyperlink: Option<Arc<str>>,
38    },
39    Continuation,
40    Empty,
41}
42
43#[derive(Debug, Clone)]
44pub struct RenderOpts {
45    pub tab_width: u8,
46    pub wrap: bool,
47    pub cols: u16,
48    pub mode: AnsiMode,
49}
50
51impl Default for RenderOpts {
52    fn default() -> Self {
53        Self { tab_width: 8, wrap: true, cols: 80, mode: AnsiMode::Strict }
54    }
55}
56
57/// Try to decode one grapheme cluster starting at `bytes[i]`.
58/// Returns the cluster as &str and number of bytes consumed.
59/// Returns None if `bytes[i..]` does not begin with a valid UTF-8 sequence.
60fn decode_cluster(bytes: &[u8], i: usize) -> Option<(&str, usize)> {
61    // Find the longest valid UTF-8 prefix starting at i (capped at 4 bytes
62    // for the first codepoint, then continue while next codepoint is a
63    // zero-width continuation of the same cluster).
64    // Strategy: try to validate up to 4 bytes for the leading codepoint,
65    // then extend as long as additional codepoints belong to the same cluster.
66
67    // First, validate one codepoint.
68    let max = (i + 4).min(bytes.len());
69    let mut end = i;
70    for try_end in (i + 1)..=max {
71        if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
72            end = try_end;
73            break;
74        }
75    }
76    if end == i {
77        return None;
78    }
79
80    // Now extend by additional valid codepoints that the segmenter groups
81    // into the first cluster. Use unicode-segmentation for cluster boundaries.
82    // We keep adding bytes (validated as UTF-8) until the cluster boundary
83    // changes or we run out of bytes.
84    let mut probe_end = end;
85    loop {
86        // Try extending by up to 4 more bytes.
87        let probe_max = (probe_end + 4).min(bytes.len());
88        let mut next_end = probe_end;
89        for try_end in (probe_end + 1)..=probe_max {
90            if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
91                next_end = try_end;
92                break;
93            }
94        }
95        if next_end == probe_end {
96            break;
97        }
98        let candidate = std::str::from_utf8(&bytes[i..next_end]).unwrap();
99        let cluster_count = candidate.graphemes(true).count();
100        if cluster_count > 1 {
101            // Adding broke into a new cluster; stop at probe_end.
102            break;
103        }
104        probe_end = next_end;
105    }
106
107    Some((std::str::from_utf8(&bytes[i..probe_end]).unwrap(), probe_end - i))
108}
109
110/// In `AnsiMode::Interpret`, pre-filter the raw byte stream through the ANSI
111/// parser and return a list of `(byte, style_at_byte, hyperlink_at_byte)` for
112/// printable bytes only. ESC sequences consume bytes but produce no entries.
113///
114/// In `AnsiMode::Strict` / `AnsiMode::Raw`, every byte is printable (no
115/// pre-filtering). Style is default and hyperlink is None for all entries.
116fn prefilter(
117    bytes: &[u8],
118    mode: AnsiMode,
119    state: Option<&mut RenderState>,
120) -> Vec<(u8, crate::ansi::Style, Option<Arc<str>>)> {
121    match mode {
122        AnsiMode::Strict | AnsiMode::Raw => {
123            // Bypass: every byte is printable with default style. Raw passthrough
124            // is handled by the writer layer, not the render kernel.
125            bytes
126                .iter()
127                .map(|&b| (b, crate::ansi::Style::default(), None))
128                .collect()
129        }
130        AnsiMode::Interpret => {
131            use crate::ansi::ParseStep;
132            // Use a temporary local state when the caller passes None.
133            let mut tmp;
134            let st: &mut RenderState = match state {
135                Some(s) => s,
136                None => {
137                    tmp = RenderState::default();
138                    &mut tmp
139                }
140            };
141            let mut out = Vec::with_capacity(bytes.len());
142            for &b in bytes {
143                let step =
144                    crate::ansi::step(&mut st.parse, &mut st.style, &mut st.hyperlink, b);
145                if let ParseStep::Printable(pb) = step {
146                    let hl = st.hyperlink.as_deref().map(Arc::from);
147                    out.push((pb, st.style, hl));
148                }
149            }
150            out
151        }
152    }
153}
154
155pub fn render_line(
156    bytes: &[u8],
157    opts: &RenderOpts,
158    state: Option<&mut RenderState>,
159) -> Vec<Vec<Cell>> {
160    let cols = opts.cols as usize;
161    let mut rows: Vec<Vec<Cell>> = Vec::new();
162    let mut current: Vec<Cell> = Vec::with_capacity(cols);
163
164    // Pre-filter: resolve styles and strip escape sequences for Interpret mode.
165    let filtered = prefilter(bytes, opts.mode, state);
166
167    fn push(current: &mut Vec<Cell>, rows: &mut Vec<Vec<Cell>>, cell: Cell, opts: &RenderOpts) {
168        if current.len() >= opts.cols as usize {
169            if opts.wrap {
170                let mut full = std::mem::replace(current, Vec::with_capacity(opts.cols as usize));
171                while full.len() < opts.cols as usize { full.push(Cell::Empty); }
172                rows.push(full);
173            } else {
174                return;
175            }
176        }
177        current.push(cell);
178    }
179
180    fn push_str(
181        current: &mut Vec<Cell>,
182        rows: &mut Vec<Vec<Cell>>,
183        s: &str,
184        style: crate::ansi::Style,
185        hyperlink: Option<Arc<str>>,
186        opts: &RenderOpts,
187    ) {
188        for c in s.chars() {
189            push(
190                current,
191                rows,
192                Cell::Char { ch: c, width: 1, style, hyperlink: hyperlink.clone() },
193                opts,
194            );
195        }
196    }
197
198    fn push_wide(
199        current: &mut Vec<Cell>,
200        rows: &mut Vec<Vec<Cell>>,
201        ch: char,
202        width: u8,
203        style: crate::ansi::Style,
204        hyperlink: Option<Arc<str>>,
205        opts: &RenderOpts,
206    ) {
207        let cols = opts.cols as usize;
208        // If the wide char wouldn't fit in the remainder of this row, wrap first.
209        if current.len() + width as usize > cols {
210            if opts.wrap {
211                let mut full = std::mem::replace(current, Vec::with_capacity(cols));
212                while full.len() < cols { full.push(Cell::Empty); }
213                rows.push(full);
214            } else {
215                return; // chop
216            }
217        }
218        current.push(Cell::Char { ch, width, style, hyperlink });
219        for _ in 1..width {
220            current.push(Cell::Continuation);
221        }
222    }
223
224    // Walk filtered bytes (raw bytes for Strict, printable-only for Interpret).
225    let mut i = 0;
226    while i < filtered.len() {
227        let (b, style, hyperlink) = filtered[i].clone();
228        if b == b'\t' {
229            let stop = opts.tab_width.max(1) as usize;
230            let cur_col = current.len();
231            let next_stop = ((cur_col / stop) + 1) * stop;
232            for _ in cur_col..next_stop {
233                push(
234                    &mut current,
235                    &mut rows,
236                    Cell::Char { ch: ' ', width: 1, style, hyperlink: hyperlink.clone() },
237                    opts,
238                );
239            }
240            i += 1;
241        } else if b == b'\n' {
242            i += 1;
243        } else if b < 0x20 || b == 0x7F {
244            let printable = if b == 0x7F { '?' } else { (b ^ 0x40) as char };
245            push(
246                &mut current,
247                &mut rows,
248                Cell::Char { ch: '^', width: 1, style, hyperlink: hyperlink.clone() },
249                opts,
250            );
251            push(
252                &mut current,
253                &mut rows,
254                Cell::Char { ch: printable, width: 1, style, hyperlink },
255                opts,
256            );
257            i += 1;
258        } else {
259            // Try to decode a UTF-8 grapheme cluster. We reconstruct raw bytes
260            // from the filtered stream for cluster decoding.
261            let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
262            match decode_cluster(&raw_bytes, 0) {
263                Some((cluster, consumed)) => {
264                    let w = UnicodeWidthStr::width(cluster) as u8;
265                    let base_char = cluster.chars().next().unwrap_or('\u{FFFD}');
266                    if w == 0 {
267                        // Lone combining mark with no base — emit replacement.
268                        push(
269                            &mut current,
270                            &mut rows,
271                            Cell::Char {
272                                ch: '\u{FFFD}',
273                                width: 1,
274                                style,
275                                hyperlink,
276                            },
277                            opts,
278                        );
279                    } else {
280                        push_wide(&mut current, &mut rows, base_char, w, style, hyperlink, opts);
281                    }
282                    i += consumed;
283                }
284                None => {
285                    // Invalid byte: emit <HH>, advance one byte.
286                    let s = format!("<{:02X}>", b);
287                    push_str(&mut current, &mut rows, &s, style, hyperlink, opts);
288                    i += 1;
289                }
290            }
291        }
292    }
293
294    while current.len() < cols {
295        current.push(Cell::Empty);
296    }
297    rows.push(current);
298    rows
299}
300
301pub fn count_rows(
302    bytes: &[u8],
303    opts: &RenderOpts,
304    state: Option<&mut RenderState>,
305) -> usize {
306    if !opts.wrap {
307        return 1;
308    }
309    let cols = opts.cols.max(1) as usize;
310    let mut col = 0usize;
311    let mut rows = 1usize;
312
313    let bump = |w: usize, col: &mut usize, rows: &mut usize| {
314        if *col + w > cols {
315            *rows += 1;
316            *col = 0;
317        }
318        *col += w;
319    };
320
321    // Pre-filter: only printable bytes contribute to column count.
322    let filtered = prefilter(bytes, opts.mode, state);
323
324    let mut i = 0;
325    while i < filtered.len() {
326        let (b, _, _) = filtered[i];
327        if b == b'\t' {
328            let stop = opts.tab_width.max(1) as usize;
329            let next_stop = ((col / stop) + 1) * stop;
330            let advance = next_stop - col;
331            // Tabs may overflow into multiple wraps if cols < tab_width.
332            for _ in 0..advance {
333                bump(1, &mut col, &mut rows);
334            }
335            i += 1;
336        } else if b == b'\n' {
337            i += 1;
338        } else if b < 0x20 || b == 0x7F {
339            bump(1, &mut col, &mut rows); // ^
340            bump(1, &mut col, &mut rows); // X
341            i += 1;
342        } else {
343            let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
344            match decode_cluster(&raw_bytes, 0) {
345                Some((cluster, consumed)) => {
346                    let w = UnicodeWidthStr::width(cluster);
347                    let w = if w == 0 { 1 } else { w };
348                    bump(w, &mut col, &mut rows);
349                    i += consumed;
350                }
351                None => {
352                    // <HH> = 4 cells
353                    for _ in 0..4 { bump(1, &mut col, &mut rows); }
354                    i += 1;
355                }
356            }
357        }
358    }
359    rows
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    fn opts(cols: u16, wrap: bool) -> RenderOpts {
367        RenderOpts { tab_width: 8, wrap, cols, mode: AnsiMode::Strict }
368    }
369
370    fn ch(c: char) -> Cell {
371        Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None }
372    }
373
374    #[test]
375    fn ascii_short_line_pads_to_cols() {
376        let rows = render_line(b"hi", &opts(5, true), None);
377        assert_eq!(rows.len(), 1);
378        assert_eq!(rows[0], vec![ch('h'), ch('i'), Cell::Empty, Cell::Empty, Cell::Empty]);
379    }
380
381    #[test]
382    fn ascii_exact_width() {
383        let rows = render_line(b"hello", &opts(5, true), None);
384        assert_eq!(rows.len(), 1);
385        assert_eq!(rows[0], vec![ch('h'), ch('e'), ch('l'), ch('l'), ch('o')]);
386    }
387
388    #[test]
389    fn empty_input_yields_one_empty_row() {
390        let rows = render_line(b"", &opts(3, true), None);
391        assert_eq!(rows, vec![vec![Cell::Empty, Cell::Empty, Cell::Empty]]);
392    }
393
394    #[test]
395    fn tab_at_col_zero_expands_to_eight() {
396        let rows = render_line(b"\tx", &opts(20, true), None);
397        // Eight spaces, then 'x', then padding.
398        for (i, cell) in rows[0].iter().take(8).enumerate() {
399            assert_eq!(*cell, ch(' '), "col {i} should be space");
400        }
401        assert_eq!(rows[0][8], ch('x'));
402    }
403
404    #[test]
405    fn tab_at_col_three_advances_to_next_stop() {
406        // "abc\tx" → cols 0,1,2 = a,b,c; tab fills to col 8 with spaces; col 8 = x
407        let rows = render_line(b"abc\tx", &opts(20, true), None);
408        assert_eq!(rows[0][0], ch('a'));
409        assert_eq!(rows[0][2], ch('c'));
410        for cell in rows[0].iter().skip(3).take(5) {
411            assert_eq!(*cell, ch(' '));
412        }
413        assert_eq!(rows[0][8], ch('x'));
414    }
415
416    #[test]
417    fn tab_at_col_eight_advances_to_sixteen() {
418        let mut input = vec![b'a'; 8];
419        input.push(b'\t');
420        input.push(b'x');
421        let rows = render_line(&input, &opts(20, true), None);
422        for cell in rows[0].iter().skip(8).take(8) {
423            assert_eq!(*cell, ch(' '));
424        }
425        assert_eq!(rows[0][16], ch('x'));
426    }
427
428    #[test]
429    fn null_renders_as_caret_at() {
430        let rows = render_line(b"\0", &opts(5, true), None);
431        assert_eq!(rows[0][0], ch('^'));
432        assert_eq!(rows[0][1], ch('@'));
433    }
434
435    #[test]
436    fn esc_renders_as_caret_lbracket() {
437        let rows = render_line(b"\x1b", &opts(5, true), None);
438        assert_eq!(rows[0][0], ch('^'));
439        assert_eq!(rows[0][1], ch('['));
440    }
441
442    #[test]
443    fn del_renders_as_caret_question() {
444        let rows = render_line(b"\x7f", &opts(5, true), None);
445        assert_eq!(rows[0][0], ch('^'));
446        assert_eq!(rows[0][1], ch('?'));
447    }
448
449    #[test]
450    fn invalid_utf8_byte_renders_as_angle_hex() {
451        let rows = render_line(&[0xFF], &opts(8, true), None);
452        assert_eq!(rows[0][0], ch('<'));
453        assert_eq!(rows[0][1], ch('F'));
454        assert_eq!(rows[0][2], ch('F'));
455        assert_eq!(rows[0][3], ch('>'));
456    }
457
458    #[test]
459    fn partial_multibyte_each_byte_renders_separately() {
460        // 0xC3 starts a 2-byte sequence; alone it's invalid → <C3>
461        let rows = render_line(&[0xC3], &opts(8, true), None);
462        assert_eq!(rows[0][0], ch('<'));
463        assert_eq!(rows[0][1], ch('C'));
464        assert_eq!(rows[0][2], ch('3'));
465        assert_eq!(rows[0][3], ch('>'));
466    }
467
468    #[test]
469    fn single_byte_utf8_e_acute() {
470        let rows = render_line("é".as_bytes(), &opts(5, true), None);
471        assert_eq!(
472            rows[0][0],
473            Cell::Char { ch: 'é', width: 1, style: crate::ansi::Style::default(), hyperlink: None }
474        );
475    }
476
477    #[test]
478    fn cjk_char_takes_two_columns() {
479        // 日 is width 2.
480        let rows = render_line("日".as_bytes(), &opts(5, true), None);
481        assert_eq!(
482            rows[0][0],
483            Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
484        );
485        assert_eq!(rows[0][1], Cell::Continuation);
486        assert_eq!(rows[0][2], Cell::Empty);
487    }
488
489    #[test]
490    fn emoji_takes_two_columns() {
491        let rows = render_line("🦀".as_bytes(), &opts(5, true), None);
492        // Width depends on unicode-width; crab emoji is width 2.
493        assert!(matches!(rows[0][0], Cell::Char { width: 2, .. }));
494        assert_eq!(rows[0][1], Cell::Continuation);
495    }
496
497    #[test]
498    fn combining_mark_folds_into_prior_cell() {
499        // "e\u{0301}" is one grapheme cluster (e with combining acute).
500        let rows = render_line("e\u{0301}".as_bytes(), &opts(5, true), None);
501        // Cluster renders as a single cell carrying base char.
502        assert!(matches!(rows[0][0], Cell::Char { width: 1, .. }));
503        assert_eq!(rows[0][1], Cell::Empty);
504    }
505
506    #[test]
507    fn wrap_long_line_into_multiple_rows() {
508        let rows = render_line(b"abcdefghij", &opts(4, true), None);
509        assert_eq!(rows.len(), 3);
510        assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
511        assert_eq!(rows[1], vec![ch('e'), ch('f'), ch('g'), ch('h')]);
512        assert_eq!(rows[2], vec![ch('i'), ch('j'), Cell::Empty, Cell::Empty]);
513    }
514
515    #[test]
516    fn chop_long_line_truncates() {
517        let rows = render_line(b"abcdefghij", &opts(4, false), None);
518        assert_eq!(rows.len(), 1);
519        assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
520    }
521
522    #[test]
523    fn wide_char_at_boundary_pushed_to_next_row() {
524        // cols=3, content "ab日" — 日 is width 2, doesn't fit at col 2,
525        // so row 0 = a, b, Empty; row 1 = 日(continuation), Empty.
526        let rows = render_line("ab日".as_bytes(), &opts(3, true), None);
527        assert_eq!(rows.len(), 2);
528        assert_eq!(rows[0], vec![ch('a'), ch('b'), Cell::Empty]);
529        assert_eq!(
530            rows[1][0],
531            Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
532        );
533        assert_eq!(rows[1][1], Cell::Continuation);
534        assert_eq!(rows[1][2], Cell::Empty);
535    }
536
537    #[test]
538    fn count_rows_matches_render_line_for_short() {
539        let o = opts(80, true);
540        let bytes = b"hello world";
541        assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
542    }
543
544    #[test]
545    fn count_rows_matches_render_line_for_long_wrap() {
546        let o = opts(4, true);
547        let bytes = b"abcdefghij";
548        assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
549    }
550
551    #[test]
552    fn count_rows_chop_is_one() {
553        let o = opts(4, false);
554        let bytes = b"abcdefghij";
555        assert_eq!(count_rows(bytes, &o, None), 1);
556    }
557
558    #[test]
559    fn count_rows_handles_wide_char() {
560        let o = opts(3, true);
561        let bytes = "ab日".as_bytes();
562        assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
563    }
564
565    // ---- Interpret-mode tests ----
566
567    fn interpret_opts() -> RenderOpts {
568        RenderOpts { mode: AnsiMode::Interpret, ..Default::default() }
569    }
570
571    #[test]
572    fn interpret_red_text() {
573        let mut state = RenderState::default();
574        let rows = render_line(b"\x1b[31mhi", &interpret_opts(), Some(&mut state));
575        let cells: Vec<&Cell> =
576            rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
577        assert_eq!(cells.len(), 2);
578        for c in cells {
579            if let Cell::Char { style, .. } = c {
580                assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
581            }
582        }
583    }
584
585    #[test]
586    fn interpret_truecolor() {
587        let mut state = RenderState::default();
588        let rows =
589            render_line(b"\x1b[38;2;255;0;0mfoo", &interpret_opts(), Some(&mut state));
590        let cells: Vec<&Cell> =
591            rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
592        for c in cells {
593            if let Cell::Char { style, .. } = c {
594                assert_eq!(style.fg, Some(crate::ansi::Color::Rgb(255, 0, 0)));
595            }
596        }
597    }
598
599    #[test]
600    fn interpret_wide_char_carries_color() {
601        let mut state = RenderState::default();
602        let rows =
603            render_line("\x1b[31m日".as_bytes(), &interpret_opts(), Some(&mut state));
604        let jp_cell = rows.iter().flatten().find_map(|c| match c {
605            Cell::Char { ch: '日', style, width, .. } => Some((style, *width)),
606            _ => None,
607        });
608        let (style, width) = jp_cell.expect("expected 日 cell");
609        assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
610        assert_eq!(width, 2);
611    }
612
613    #[test]
614    fn interpret_state_persists_across_calls() {
615        let mut state = RenderState::default();
616        let _ = render_line(b"\x1b[31mline1", &interpret_opts(), Some(&mut state));
617        let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
618        let l_cell = rows.iter().flatten().find_map(|c| match c {
619            Cell::Char { ch: 'l', style, .. } => Some(style),
620            _ => None,
621        });
622        assert_eq!(
623            l_cell.expect("expected l cell").fg,
624            Some(crate::ansi::Color::Ansi(1))
625        );
626    }
627
628    #[test]
629    fn interpret_reset_clears_state() {
630        let mut state = RenderState::default();
631        let _ =
632            render_line(b"\x1b[31mline1\x1b[0m", &interpret_opts(), Some(&mut state));
633        let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
634        let l_cell = rows.iter().flatten().find_map(|c| match c {
635            Cell::Char { ch: 'l', style, .. } => Some(style),
636            _ => None,
637        });
638        assert_eq!(l_cell.expect("expected l cell"), &crate::ansi::Style::default());
639    }
640
641    #[test]
642    fn interpret_non_sgr_csi_is_zero_width() {
643        let mut state = RenderState::default();
644        let rows = render_line(b"\x1b[2Jdata", &interpret_opts(), Some(&mut state));
645        let chars: String = rows
646            .iter()
647            .flatten()
648            .filter_map(|c| match c {
649                Cell::Char { ch, .. } => Some(*ch),
650                _ => None,
651            })
652            .collect();
653        assert_eq!(chars, "data");
654    }
655
656    #[test]
657    fn strict_mode_esc_still_renders_as_caret_lbracket() {
658        // LOCKDOWN: pre-0.18 behavior must survive.
659        let rows = render_line(b"\x1b[31mhi", &RenderOpts::default(), None);
660        let chars: String = rows
661            .iter()
662            .flatten()
663            .filter_map(|c| match c {
664                Cell::Char { ch, .. } => Some(*ch),
665                _ => None,
666            })
667            .collect();
668        assert!(chars.starts_with("^["), "got: {chars:?}");
669    }
670
671    #[test]
672    fn osc8_hyperlink_attached_to_cells() {
673        let mut state = RenderState::default();
674        let rows = render_line(
675            b"\x1b]8;;https://example.com\x07click\x1b]8;;\x07",
676            &interpret_opts(),
677            Some(&mut state),
678        );
679        let click_cell = rows.iter().flatten().find_map(|c| match c {
680            Cell::Char { ch: 'c', hyperlink, .. } => Some(hyperlink.clone()),
681            _ => None,
682        });
683        let link = click_cell.expect("expected c cell").expect("expected hyperlink");
684        assert_eq!(link.as_ref(), "https://example.com");
685    }
686}