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    /// In chop mode, when a line overflows the right edge, replace the
50    /// last cell with this character to signal "more content right".
51    /// `None` disables the marker. Matches less's `--rscroll=c`.
52    pub rscroll_char: Option<char>,
53    /// In wrap mode, break lines on whitespace boundaries instead of
54    /// mid-character when possible. Falls back to mid-character break
55    /// when no whitespace fits in the row. Matches less's `--wordwrap`.
56    pub word_wrap: bool,
57}
58
59impl Default for RenderOpts {
60    fn default() -> Self {
61        Self {
62            tab_width: 8, wrap: true, cols: 80,
63            mode: AnsiMode::Strict, rscroll_char: None, word_wrap: false,
64        }
65    }
66}
67
68/// Whether the writer should pass 24-bit RGB colors through to the terminal
69/// or downsample to the 256-color cube first. Resolved once at startup.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum TrueColor {
72    Always,
73    Never,
74    /// Inspect `$COLORTERM` to decide.
75    #[default]
76    Auto,
77}
78
79impl TrueColor {
80    /// Resolve this mode to a concrete pass-through flag. `Auto` looks at
81    /// the `COLORTERM` env var and treats values `truecolor` / `24bit` as
82    /// supporting truecolor.
83    pub fn resolve(self) -> bool {
84        match self {
85            TrueColor::Always => true,
86            TrueColor::Never => false,
87            TrueColor::Auto => matches!(
88                std::env::var("COLORTERM").ok().as_deref(),
89                Some("truecolor") | Some("24bit"),
90            ),
91        }
92    }
93}
94
95/// Downsample 24-bit RGB to the xterm 256-color palette. Uses the standard
96/// 6×6×6 cube plus the 24-step grayscale ramp.
97pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
98    if r == g && g == b {
99        if r < 8 { return 16; }
100        if r > 248 { return 231; }
101        return 232 + ((r as u16 - 8) * 24 / 240) as u8;
102    }
103    let q = |c: u8| -> u8 {
104        if c < 48 { 0 }
105        else if c < 115 { 1 }
106        else { ((c as u16 - 35) / 40) as u8 }
107    };
108    16 + 36 * q(r) + 6 * q(g) + q(b)
109}
110
111/// Try to decode one grapheme cluster starting at `bytes[i]`.
112/// Returns the cluster as &str and number of bytes consumed.
113/// Returns None if `bytes[i..]` does not begin with a valid UTF-8 sequence.
114fn decode_cluster(bytes: &[u8], i: usize) -> Option<(&str, usize)> {
115    // Find the longest valid UTF-8 prefix starting at i (capped at 4 bytes
116    // for the first codepoint, then continue while next codepoint is a
117    // zero-width continuation of the same cluster).
118    // Strategy: try to validate up to 4 bytes for the leading codepoint,
119    // then extend as long as additional codepoints belong to the same cluster.
120
121    // First, validate one codepoint.
122    let max = (i + 4).min(bytes.len());
123    let mut end = i;
124    for try_end in (i + 1)..=max {
125        if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
126            end = try_end;
127            break;
128        }
129    }
130    if end == i {
131        return None;
132    }
133
134    // Now extend by additional valid codepoints that the segmenter groups
135    // into the first cluster. Use unicode-segmentation for cluster boundaries.
136    // We keep adding bytes (validated as UTF-8) until the cluster boundary
137    // changes or we run out of bytes.
138    let mut probe_end = end;
139    loop {
140        // Try extending by up to 4 more bytes.
141        let probe_max = (probe_end + 4).min(bytes.len());
142        let mut next_end = probe_end;
143        for try_end in (probe_end + 1)..=probe_max {
144            if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
145                next_end = try_end;
146                break;
147            }
148        }
149        if next_end == probe_end {
150            break;
151        }
152        let candidate = std::str::from_utf8(&bytes[i..next_end]).unwrap();
153        let cluster_count = candidate.graphemes(true).count();
154        if cluster_count > 1 {
155            // Adding broke into a new cluster; stop at probe_end.
156            break;
157        }
158        probe_end = next_end;
159    }
160
161    Some((std::str::from_utf8(&bytes[i..probe_end]).unwrap(), probe_end - i))
162}
163
164/// In `AnsiMode::Interpret`, pre-filter the raw byte stream through the ANSI
165/// parser and return a list of `(byte, style_at_byte, hyperlink_at_byte)` for
166/// printable bytes only. ESC sequences consume bytes but produce no entries.
167///
168/// In `AnsiMode::Strict` / `AnsiMode::Raw`, every byte is printable (no
169/// pre-filtering). Style is default and hyperlink is None for all entries.
170fn prefilter(
171    bytes: &[u8],
172    mode: AnsiMode,
173    state: Option<&mut RenderState>,
174) -> Vec<(u8, crate::ansi::Style, Option<Arc<str>>)> {
175    match mode {
176        AnsiMode::Strict | AnsiMode::Raw => {
177            // Bypass: every byte is printable with default style. Raw passthrough
178            // is handled by the writer layer, not the render kernel.
179            bytes
180                .iter()
181                .map(|&b| (b, crate::ansi::Style::default(), None))
182                .collect()
183        }
184        AnsiMode::Interpret => {
185            use crate::ansi::ParseStep;
186            // Use a temporary local state when the caller passes None.
187            let mut tmp;
188            let st: &mut RenderState = match state {
189                Some(s) => s,
190                None => {
191                    tmp = RenderState::default();
192                    &mut tmp
193                }
194            };
195            let mut out = Vec::with_capacity(bytes.len());
196            for &b in bytes {
197                let step =
198                    crate::ansi::step(&mut st.parse, &mut st.style, &mut st.hyperlink, b);
199                if let ParseStep::Printable(pb) = step {
200                    let hl = st.hyperlink.as_deref().map(Arc::from);
201                    out.push((pb, st.style, hl));
202                }
203            }
204            out
205        }
206    }
207}
208
209pub fn render_line(
210    bytes: &[u8],
211    opts: &RenderOpts,
212    state: Option<&mut RenderState>,
213) -> Vec<Vec<Cell>> {
214    let cols = opts.cols as usize;
215    let mut rows: Vec<Vec<Cell>> = Vec::new();
216    let mut current: Vec<Cell> = Vec::with_capacity(cols);
217
218    // Pre-filter: resolve styles and strip escape sequences for Interpret mode.
219    let filtered = prefilter(bytes, opts.mode, state);
220
221    /// Returns true if the cell was dropped due to chop-mode overflow.
222    /// The caller uses this to decide whether to paint the `rscroll` marker.
223    fn push(current: &mut Vec<Cell>, rows: &mut Vec<Vec<Cell>>, cell: Cell, opts: &RenderOpts) -> bool {
224        if current.len() >= opts.cols as usize {
225            if opts.wrap {
226                let mut full = std::mem::replace(current, Vec::with_capacity(opts.cols as usize));
227                // `--wordwrap`: prefer a break on the last whitespace cell.
228                // Anything past the break carries over to the next row as
229                // its leading content. Falls back to mid-character break
230                // when no whitespace is found.
231                if opts.word_wrap {
232                    if let Some(ws_idx) = (0..full.len()).rev().find(|&i| matches!(
233                        full[i],
234                        Cell::Char { ch, .. } if ch == ' ' || ch == '\t'
235                    )) {
236                        // Carry everything after the whitespace into the new
237                        // current row (so the next word starts at column 0).
238                        let carry: Vec<Cell> = full.drain((ws_idx + 1)..).collect();
239                        *current = carry;
240                    }
241                }
242                while full.len() < opts.cols as usize { full.push(Cell::Empty); }
243                rows.push(full);
244            } else {
245                return true;
246            }
247        }
248        current.push(cell);
249        false
250    }
251
252    fn push_str(
253        current: &mut Vec<Cell>,
254        rows: &mut Vec<Vec<Cell>>,
255        s: &str,
256        style: crate::ansi::Style,
257        hyperlink: Option<Arc<str>>,
258        opts: &RenderOpts,
259    ) -> bool {
260        let mut overflowed = false;
261        for c in s.chars() {
262            overflowed |= push(
263                current,
264                rows,
265                Cell::Char { ch: c, width: 1, style, hyperlink: hyperlink.clone() },
266                opts,
267            );
268        }
269        overflowed
270    }
271
272    fn push_wide(
273        current: &mut Vec<Cell>,
274        rows: &mut Vec<Vec<Cell>>,
275        ch: char,
276        width: u8,
277        style: crate::ansi::Style,
278        hyperlink: Option<Arc<str>>,
279        opts: &RenderOpts,
280    ) -> bool {
281        let cols = opts.cols as usize;
282        // If the wide char wouldn't fit in the remainder of this row, wrap first.
283        if current.len() + width as usize > cols {
284            if opts.wrap {
285                let mut full = std::mem::replace(current, Vec::with_capacity(cols));
286                // `--wordwrap`: prefer a break on the last whitespace. Same
287                // logic as in `push`; kept duplicated rather than factored
288                // out because the two helpers track `current.len()` slightly
289                // differently and the inline form is easier to follow.
290                if opts.word_wrap {
291                    if let Some(ws_idx) = (0..full.len()).rev().find(|&i| matches!(
292                        full[i],
293                        Cell::Char { ch, .. } if ch == ' ' || ch == '\t'
294                    )) {
295                        let carry: Vec<Cell> = full.drain((ws_idx + 1)..).collect();
296                        *current = carry;
297                    }
298                }
299                while full.len() < cols { full.push(Cell::Empty); }
300                rows.push(full);
301            } else {
302                return true; // chop overflow
303            }
304        }
305        current.push(Cell::Char { ch, width, style, hyperlink });
306        for _ in 1..width {
307            current.push(Cell::Continuation);
308        }
309        false
310    }
311
312    // Walk filtered bytes (raw bytes for Strict, printable-only for Interpret).
313    // Track chop-mode overflow so we can paint the rscroll marker afterward.
314    let mut overflowed = false;
315    let mut i = 0;
316    while i < filtered.len() {
317        let (b, style, hyperlink) = filtered[i].clone();
318        if b == b'\t' {
319            let stop = opts.tab_width.max(1) as usize;
320            let cur_col = current.len();
321            let next_stop = ((cur_col / stop) + 1) * stop;
322            for _ in cur_col..next_stop {
323                overflowed |= push(
324                    &mut current,
325                    &mut rows,
326                    Cell::Char { ch: ' ', width: 1, style, hyperlink: hyperlink.clone() },
327                    opts,
328                );
329            }
330            i += 1;
331        } else if b == b'\n' {
332            i += 1;
333        } else if b < 0x20 || b == 0x7F {
334            let printable = if b == 0x7F { '?' } else { (b ^ 0x40) as char };
335            overflowed |= push(
336                &mut current,
337                &mut rows,
338                Cell::Char { ch: '^', width: 1, style, hyperlink: hyperlink.clone() },
339                opts,
340            );
341            overflowed |= push(
342                &mut current,
343                &mut rows,
344                Cell::Char { ch: printable, width: 1, style, hyperlink },
345                opts,
346            );
347            i += 1;
348        } else {
349            // Try to decode a UTF-8 grapheme cluster. We reconstruct raw bytes
350            // from the filtered stream for cluster decoding.
351            let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
352            match decode_cluster(&raw_bytes, 0) {
353                Some((cluster, consumed)) => {
354                    let w = UnicodeWidthStr::width(cluster) as u8;
355                    let base_char = cluster.chars().next().unwrap_or('\u{FFFD}');
356                    if w == 0 {
357                        // Lone combining mark with no base — emit replacement.
358                        overflowed |= push(
359                            &mut current,
360                            &mut rows,
361                            Cell::Char {
362                                ch: '\u{FFFD}',
363                                width: 1,
364                                style,
365                                hyperlink,
366                            },
367                            opts,
368                        );
369                    } else {
370                        overflowed |= push_wide(&mut current, &mut rows, base_char, w, style, hyperlink, opts);
371                    }
372                    i += consumed;
373                }
374                None => {
375                    // Invalid byte: emit <HH>, advance one byte.
376                    let s = format!("<{:02X}>", b);
377                    overflowed |= push_str(&mut current, &mut rows, &s, style, hyperlink, opts);
378                    i += 1;
379                }
380            }
381        }
382    }
383
384    while current.len() < cols {
385        current.push(Cell::Empty);
386    }
387
388    // `--rscroll`: in chop mode, when the line overflowed the right edge,
389    // replace the last cell with the marker char (styled dim) so the user
390    // can see that content was truncated.
391    if !opts.wrap && overflowed && cols > 0 {
392        if let Some(marker) = opts.rscroll_char {
393            current[cols - 1] = Cell::Char {
394                ch: marker,
395                width: 1,
396                style: crate::ansi::Style { dim: true, ..Default::default() },
397                hyperlink: None,
398            };
399        }
400    }
401
402    rows.push(current);
403    rows
404}
405
406pub fn count_rows(
407    bytes: &[u8],
408    opts: &RenderOpts,
409    state: Option<&mut RenderState>,
410) -> usize {
411    if !opts.wrap {
412        return 1;
413    }
414    let cols = opts.cols.max(1) as usize;
415    let mut col = 0usize;
416    let mut rows = 1usize;
417
418    let bump = |w: usize, col: &mut usize, rows: &mut usize| {
419        if *col + w > cols {
420            *rows += 1;
421            *col = 0;
422        }
423        *col += w;
424    };
425
426    // Pre-filter: only printable bytes contribute to column count.
427    let filtered = prefilter(bytes, opts.mode, state);
428
429    let mut i = 0;
430    while i < filtered.len() {
431        let (b, _, _) = filtered[i];
432        if b == b'\t' {
433            let stop = opts.tab_width.max(1) as usize;
434            let next_stop = ((col / stop) + 1) * stop;
435            let advance = next_stop - col;
436            // Tabs may overflow into multiple wraps if cols < tab_width.
437            for _ in 0..advance {
438                bump(1, &mut col, &mut rows);
439            }
440            i += 1;
441        } else if b == b'\n' {
442            i += 1;
443        } else if b < 0x20 || b == 0x7F {
444            bump(1, &mut col, &mut rows); // ^
445            bump(1, &mut col, &mut rows); // X
446            i += 1;
447        } else {
448            let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
449            match decode_cluster(&raw_bytes, 0) {
450                Some((cluster, consumed)) => {
451                    let w = UnicodeWidthStr::width(cluster);
452                    let w = if w == 0 { 1 } else { w };
453                    bump(w, &mut col, &mut rows);
454                    i += consumed;
455                }
456                None => {
457                    // <HH> = 4 cells
458                    for _ in 0..4 { bump(1, &mut col, &mut rows); }
459                    i += 1;
460                }
461            }
462        }
463    }
464    rows
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn rgb_to_256_pure_corners_map_to_palette_extremes() {
473        assert_eq!(rgb_to_256(0, 0, 0), 16);
474        assert_eq!(rgb_to_256(255, 255, 255), 231);
475    }
476
477    #[test]
478    fn rgb_to_256_mid_gray_lands_in_grayscale_ramp() {
479        let n = rgb_to_256(128, 128, 128);
480        assert!((232..=255).contains(&n), "expected grayscale slot 232..=255, got {n}");
481    }
482
483    #[test]
484    fn rgb_to_256_pure_rgb_lands_in_cube_extremes() {
485        assert_eq!(rgb_to_256(255, 0, 0), 196);
486        assert_eq!(rgb_to_256(0, 255, 0), 46);
487        assert_eq!(rgb_to_256(0, 0, 255), 21);
488    }
489
490    #[test]
491    fn rgb_to_256_low_channel_quantizes_to_zero() {
492        // 256-cube index = 16 + 36*r6 + 6*g6 + b6, here r6=0 g6=4 b6=0 -> 40.
493        assert_eq!(rgb_to_256(40, 200, 0), 40);
494    }
495
496    #[test]
497    fn rgb_to_256_near_black_gray_is_palette_black() {
498        assert_eq!(rgb_to_256(5, 5, 5), 16);
499    }
500
501    #[test]
502    fn rgb_to_256_near_white_gray_is_palette_white() {
503        assert_eq!(rgb_to_256(250, 250, 250), 231);
504    }
505
506    #[test]
507    fn truecolor_always_resolves_true_regardless_of_env() {
508        assert!(TrueColor::Always.resolve());
509    }
510
511    #[test]
512    fn truecolor_never_resolves_false_regardless_of_env() {
513        assert!(!TrueColor::Never.resolve());
514    }
515
516    #[test]
517    fn rscroll_marker_appears_on_chopped_row() {
518        let mut o = opts(5, false); // 5 cols, chop mode
519        o.rscroll_char = Some('>');
520        let rows = render_line(b"abcdefgh", &o, None);
521        assert_eq!(rows.len(), 1);
522        match &rows[0][4] {
523            Cell::Char { ch, .. } => assert_eq!(*ch, '>'),
524            other => panic!("expected `>` marker, got {other:?}"),
525        }
526    }
527
528    #[test]
529    fn rscroll_marker_absent_on_fitting_row() {
530        let mut o = opts(10, false);
531        o.rscroll_char = Some('>');
532        let rows = render_line(b"abc", &o, None);
533        match &rows[0][2] {
534            Cell::Char { ch, .. } => assert_eq!(*ch, 'c'),
535            other => panic!("expected content `c`, got {other:?}"),
536        }
537    }
538
539    #[test]
540    fn rscroll_marker_disabled_emits_normal_chop() {
541        let mut o = opts(5, false);
542        o.rscroll_char = None;
543        let rows = render_line(b"abcdefgh", &o, None);
544        match &rows[0][4] {
545            Cell::Char { ch, .. } => assert_eq!(*ch, 'e'),
546            other => panic!("expected last fitting char, got {other:?}"),
547        }
548    }
549
550    #[test]
551    fn word_wrap_breaks_on_whitespace() {
552        let mut o = opts(8, true);
553        o.word_wrap = true;
554        let rows = render_line(b"the quick brown fox", &o, None);
555        // First row should break at the last whitespace before col 8.
556        let r0: String = rows[0].iter().filter_map(|c| match c {
557            Cell::Char { ch, .. } => Some(*ch),
558            _ => None,
559        }).collect();
560        assert_eq!(r0.trim_end(), "the");
561    }
562
563    #[test]
564    fn word_wrap_falls_back_when_no_whitespace_fits() {
565        let mut o = opts(5, true);
566        o.word_wrap = true;
567        let rows = render_line(b"antidisestablishment", &o, None);
568        let r0: String = rows[0].iter().filter_map(|c| match c {
569            Cell::Char { ch, .. } => Some(*ch),
570            _ => None,
571        }).collect();
572        // No whitespace anywhere → mid-character break preserved.
573        assert_eq!(r0.trim_end(), "antid");
574    }
575
576    #[test]
577    fn word_wrap_off_breaks_mid_word() {
578        let mut o = opts(8, true);
579        o.word_wrap = false;
580        let rows = render_line(b"the quick brown fox", &o, None);
581        let r0: String = rows[0].iter().filter_map(|c| match c {
582            Cell::Char { ch, .. } => Some(*ch),
583            _ => None,
584        }).collect();
585        // First 8 chars verbatim: "the quic"
586        assert_eq!(r0.trim_end(), "the quic");
587    }
588
589    #[test]
590    fn rscroll_marker_absent_in_wrap_mode() {
591        let mut o = opts(5, true);
592        o.rscroll_char = Some('>');
593        let rows = render_line(b"abcdefgh", &o, None);
594        // Wrap mode produces multiple rows; rscroll only fires in chop.
595        assert!(rows.len() > 1);
596        for row in &rows {
597            for cell in row {
598                if let Cell::Char { ch, .. } = cell {
599                    assert_ne!(*ch, '>', "rscroll marker leaked into wrap mode");
600                }
601            }
602        }
603    }
604
605    fn opts(cols: u16, wrap: bool) -> RenderOpts {
606        RenderOpts { tab_width: 8, wrap, cols, mode: AnsiMode::Strict, rscroll_char: None, word_wrap: false }
607    }
608
609    fn ch(c: char) -> Cell {
610        Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None }
611    }
612
613    #[test]
614    fn ascii_short_line_pads_to_cols() {
615        let rows = render_line(b"hi", &opts(5, true), None);
616        assert_eq!(rows.len(), 1);
617        assert_eq!(rows[0], vec![ch('h'), ch('i'), Cell::Empty, Cell::Empty, Cell::Empty]);
618    }
619
620    #[test]
621    fn ascii_exact_width() {
622        let rows = render_line(b"hello", &opts(5, true), None);
623        assert_eq!(rows.len(), 1);
624        assert_eq!(rows[0], vec![ch('h'), ch('e'), ch('l'), ch('l'), ch('o')]);
625    }
626
627    #[test]
628    fn empty_input_yields_one_empty_row() {
629        let rows = render_line(b"", &opts(3, true), None);
630        assert_eq!(rows, vec![vec![Cell::Empty, Cell::Empty, Cell::Empty]]);
631    }
632
633    #[test]
634    fn tab_at_col_zero_expands_to_eight() {
635        let rows = render_line(b"\tx", &opts(20, true), None);
636        // Eight spaces, then 'x', then padding.
637        for (i, cell) in rows[0].iter().take(8).enumerate() {
638            assert_eq!(*cell, ch(' '), "col {i} should be space");
639        }
640        assert_eq!(rows[0][8], ch('x'));
641    }
642
643    #[test]
644    fn tab_at_col_three_advances_to_next_stop() {
645        // "abc\tx" → cols 0,1,2 = a,b,c; tab fills to col 8 with spaces; col 8 = x
646        let rows = render_line(b"abc\tx", &opts(20, true), None);
647        assert_eq!(rows[0][0], ch('a'));
648        assert_eq!(rows[0][2], ch('c'));
649        for cell in rows[0].iter().skip(3).take(5) {
650            assert_eq!(*cell, ch(' '));
651        }
652        assert_eq!(rows[0][8], ch('x'));
653    }
654
655    #[test]
656    fn tab_at_col_eight_advances_to_sixteen() {
657        let mut input = vec![b'a'; 8];
658        input.push(b'\t');
659        input.push(b'x');
660        let rows = render_line(&input, &opts(20, true), None);
661        for cell in rows[0].iter().skip(8).take(8) {
662            assert_eq!(*cell, ch(' '));
663        }
664        assert_eq!(rows[0][16], ch('x'));
665    }
666
667    #[test]
668    fn null_renders_as_caret_at() {
669        let rows = render_line(b"\0", &opts(5, true), None);
670        assert_eq!(rows[0][0], ch('^'));
671        assert_eq!(rows[0][1], ch('@'));
672    }
673
674    #[test]
675    fn esc_renders_as_caret_lbracket() {
676        let rows = render_line(b"\x1b", &opts(5, true), None);
677        assert_eq!(rows[0][0], ch('^'));
678        assert_eq!(rows[0][1], ch('['));
679    }
680
681    #[test]
682    fn del_renders_as_caret_question() {
683        let rows = render_line(b"\x7f", &opts(5, true), None);
684        assert_eq!(rows[0][0], ch('^'));
685        assert_eq!(rows[0][1], ch('?'));
686    }
687
688    #[test]
689    fn invalid_utf8_byte_renders_as_angle_hex() {
690        let rows = render_line(&[0xFF], &opts(8, true), None);
691        assert_eq!(rows[0][0], ch('<'));
692        assert_eq!(rows[0][1], ch('F'));
693        assert_eq!(rows[0][2], ch('F'));
694        assert_eq!(rows[0][3], ch('>'));
695    }
696
697    #[test]
698    fn partial_multibyte_each_byte_renders_separately() {
699        // 0xC3 starts a 2-byte sequence; alone it's invalid → <C3>
700        let rows = render_line(&[0xC3], &opts(8, true), None);
701        assert_eq!(rows[0][0], ch('<'));
702        assert_eq!(rows[0][1], ch('C'));
703        assert_eq!(rows[0][2], ch('3'));
704        assert_eq!(rows[0][3], ch('>'));
705    }
706
707    #[test]
708    fn single_byte_utf8_e_acute() {
709        let rows = render_line("é".as_bytes(), &opts(5, true), None);
710        assert_eq!(
711            rows[0][0],
712            Cell::Char { ch: 'é', width: 1, style: crate::ansi::Style::default(), hyperlink: None }
713        );
714    }
715
716    #[test]
717    fn cjk_char_takes_two_columns() {
718        // 日 is width 2.
719        let rows = render_line("日".as_bytes(), &opts(5, true), None);
720        assert_eq!(
721            rows[0][0],
722            Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
723        );
724        assert_eq!(rows[0][1], Cell::Continuation);
725        assert_eq!(rows[0][2], Cell::Empty);
726    }
727
728    #[test]
729    fn emoji_takes_two_columns() {
730        let rows = render_line("🦀".as_bytes(), &opts(5, true), None);
731        // Width depends on unicode-width; crab emoji is width 2.
732        assert!(matches!(rows[0][0], Cell::Char { width: 2, .. }));
733        assert_eq!(rows[0][1], Cell::Continuation);
734    }
735
736    #[test]
737    fn combining_mark_folds_into_prior_cell() {
738        // "e\u{0301}" is one grapheme cluster (e with combining acute).
739        let rows = render_line("e\u{0301}".as_bytes(), &opts(5, true), None);
740        // Cluster renders as a single cell carrying base char.
741        assert!(matches!(rows[0][0], Cell::Char { width: 1, .. }));
742        assert_eq!(rows[0][1], Cell::Empty);
743    }
744
745    #[test]
746    fn wrap_long_line_into_multiple_rows() {
747        let rows = render_line(b"abcdefghij", &opts(4, true), None);
748        assert_eq!(rows.len(), 3);
749        assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
750        assert_eq!(rows[1], vec![ch('e'), ch('f'), ch('g'), ch('h')]);
751        assert_eq!(rows[2], vec![ch('i'), ch('j'), Cell::Empty, Cell::Empty]);
752    }
753
754    #[test]
755    fn chop_long_line_truncates() {
756        let rows = render_line(b"abcdefghij", &opts(4, false), None);
757        assert_eq!(rows.len(), 1);
758        assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
759    }
760
761    #[test]
762    fn wide_char_at_boundary_pushed_to_next_row() {
763        // cols=3, content "ab日" — 日 is width 2, doesn't fit at col 2,
764        // so row 0 = a, b, Empty; row 1 = 日(continuation), Empty.
765        let rows = render_line("ab日".as_bytes(), &opts(3, true), None);
766        assert_eq!(rows.len(), 2);
767        assert_eq!(rows[0], vec![ch('a'), ch('b'), Cell::Empty]);
768        assert_eq!(
769            rows[1][0],
770            Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
771        );
772        assert_eq!(rows[1][1], Cell::Continuation);
773        assert_eq!(rows[1][2], Cell::Empty);
774    }
775
776    #[test]
777    fn count_rows_matches_render_line_for_short() {
778        let o = opts(80, true);
779        let bytes = b"hello world";
780        assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
781    }
782
783    #[test]
784    fn count_rows_matches_render_line_for_long_wrap() {
785        let o = opts(4, true);
786        let bytes = b"abcdefghij";
787        assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
788    }
789
790    #[test]
791    fn count_rows_chop_is_one() {
792        let o = opts(4, false);
793        let bytes = b"abcdefghij";
794        assert_eq!(count_rows(bytes, &o, None), 1);
795    }
796
797    #[test]
798    fn count_rows_handles_wide_char() {
799        let o = opts(3, true);
800        let bytes = "ab日".as_bytes();
801        assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
802    }
803
804    // ---- Interpret-mode tests ----
805
806    fn interpret_opts() -> RenderOpts {
807        RenderOpts { mode: AnsiMode::Interpret, ..Default::default() }
808    }
809
810    #[test]
811    fn interpret_red_text() {
812        let mut state = RenderState::default();
813        let rows = render_line(b"\x1b[31mhi", &interpret_opts(), Some(&mut state));
814        let cells: Vec<&Cell> =
815            rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
816        assert_eq!(cells.len(), 2);
817        for c in cells {
818            if let Cell::Char { style, .. } = c {
819                assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
820            }
821        }
822    }
823
824    #[test]
825    fn interpret_truecolor() {
826        let mut state = RenderState::default();
827        let rows =
828            render_line(b"\x1b[38;2;255;0;0mfoo", &interpret_opts(), Some(&mut state));
829        let cells: Vec<&Cell> =
830            rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
831        for c in cells {
832            if let Cell::Char { style, .. } = c {
833                assert_eq!(style.fg, Some(crate::ansi::Color::Rgb(255, 0, 0)));
834            }
835        }
836    }
837
838    #[test]
839    fn interpret_wide_char_carries_color() {
840        let mut state = RenderState::default();
841        let rows =
842            render_line("\x1b[31m日".as_bytes(), &interpret_opts(), Some(&mut state));
843        let jp_cell = rows.iter().flatten().find_map(|c| match c {
844            Cell::Char { ch: '日', style, width, .. } => Some((style, *width)),
845            _ => None,
846        });
847        let (style, width) = jp_cell.expect("expected 日 cell");
848        assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
849        assert_eq!(width, 2);
850    }
851
852    #[test]
853    fn interpret_state_persists_across_calls() {
854        let mut state = RenderState::default();
855        let _ = render_line(b"\x1b[31mline1", &interpret_opts(), Some(&mut state));
856        let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
857        let l_cell = rows.iter().flatten().find_map(|c| match c {
858            Cell::Char { ch: 'l', style, .. } => Some(style),
859            _ => None,
860        });
861        assert_eq!(
862            l_cell.expect("expected l cell").fg,
863            Some(crate::ansi::Color::Ansi(1))
864        );
865    }
866
867    #[test]
868    fn interpret_reset_clears_state() {
869        let mut state = RenderState::default();
870        let _ =
871            render_line(b"\x1b[31mline1\x1b[0m", &interpret_opts(), Some(&mut state));
872        let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
873        let l_cell = rows.iter().flatten().find_map(|c| match c {
874            Cell::Char { ch: 'l', style, .. } => Some(style),
875            _ => None,
876        });
877        assert_eq!(l_cell.expect("expected l cell"), &crate::ansi::Style::default());
878    }
879
880    #[test]
881    fn interpret_non_sgr_csi_is_zero_width() {
882        let mut state = RenderState::default();
883        let rows = render_line(b"\x1b[2Jdata", &interpret_opts(), Some(&mut state));
884        let chars: String = rows
885            .iter()
886            .flatten()
887            .filter_map(|c| match c {
888                Cell::Char { ch, .. } => Some(*ch),
889                _ => None,
890            })
891            .collect();
892        assert_eq!(chars, "data");
893    }
894
895    #[test]
896    fn strict_mode_esc_still_renders_as_caret_lbracket() {
897        // LOCKDOWN: pre-0.18 behavior must survive.
898        let rows = render_line(b"\x1b[31mhi", &RenderOpts::default(), None);
899        let chars: String = rows
900            .iter()
901            .flatten()
902            .filter_map(|c| match c {
903                Cell::Char { ch, .. } => Some(*ch),
904                _ => None,
905            })
906            .collect();
907        assert!(chars.starts_with("^["), "got: {chars:?}");
908    }
909
910    #[test]
911    fn osc8_hyperlink_attached_to_cells() {
912        let mut state = RenderState::default();
913        let rows = render_line(
914            b"\x1b]8;;https://example.com\x07click\x1b]8;;\x07",
915            &interpret_opts(),
916            Some(&mut state),
917        );
918        let click_cell = rows.iter().flatten().find_map(|c| match c {
919            Cell::Char { ch: 'c', hyperlink, .. } => Some(hyperlink.clone()),
920            _ => None,
921        });
922        let link = click_cell.expect("expected c cell").expect("expected hyperlink");
923        assert_eq!(link.as_ref(), "https://example.com");
924    }
925}