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