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