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