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