1use std::sync::Arc;
2use unicode_segmentation::UnicodeSegmentation;
3use unicode_width::UnicodeWidthStr;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum AnsiMode {
8 #[default]
11 Strict,
12 Interpret,
16 Raw,
19}
20
21#[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 pub rscroll_char: Option<char>,
53 pub word_wrap: bool,
57 pub left_col: usize,
61 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
79pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
100pub enum TrueColor {
101 Always,
102 Never,
103 #[default]
105 Auto,
106}
107
108impl TrueColor {
109 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
124pub 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
140pub 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
162fn decode_cluster(bytes: &[u8], i: usize) -> Option<(&str, usize)> {
166 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 let mut probe_end = end;
190 loop {
191 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 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
215fn 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 bytes
231 .iter()
232 .map(|&b| (b, crate::ansi::Style::default(), None))
233 .collect()
234 }
235 AnsiMode::Interpret => {
236 use crate::ansi::ParseStep;
237 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 let filtered = prefilter(bytes, opts.mode, state);
271
272 let mut to_skip = if opts.wrap { 0 } else { opts.left_col };
274
275 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; 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 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 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; return false;
348 }
349 if *to_skip > 0 {
350 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 current.len() + w > cols {
361 if opts.wrap {
362 let mut full = std::mem::replace(current, Vec::with_capacity(cols));
363 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; }
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 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 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 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 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 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 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 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
492pub 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 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 }; i += consumed;
522 }
523 None => {
524 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 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 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); bump(1, &mut col, &mut rows); 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 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); 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 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 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); 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 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 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 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 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 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 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 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 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 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 let rows = render_line("e\u{0301}".as_bytes(), &opts(5, true), None);
911 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 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 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 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 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}