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
140fn decode_cluster(bytes: &[u8], i: usize) -> Option<(&str, usize)> {
144 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 let mut probe_end = end;
168 loop {
169 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 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
193fn 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 bytes
209 .iter()
210 .map(|&b| (b, crate::ansi::Style::default(), None))
211 .collect()
212 }
213 AnsiMode::Interpret => {
214 use crate::ansi::ParseStep;
215 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 let filtered = prefilter(bytes, opts.mode, state);
249
250 let mut to_skip = if opts.wrap { 0 } else { opts.left_col };
252
253 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; 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 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 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; return false;
326 }
327 if *to_skip > 0 {
328 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 current.len() + w > cols {
339 if opts.wrap {
340 let mut full = std::mem::replace(current, Vec::with_capacity(cols));
341 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; }
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 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 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 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 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 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 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 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
470pub 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 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 }; i += consumed;
500 }
501 None => {
502 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 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 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); bump(1, &mut col, &mut rows); 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 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); 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 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); 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 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 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 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 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 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 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 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 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 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 let rows = render_line("e\u{0301}".as_bytes(), &opts(5, true), None);
877 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 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 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 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 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}