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}
62
63impl Default for RenderOpts {
64 fn default() -> Self {
65 Self {
66 tab_width: 8, wrap: true, cols: 80,
67 mode: AnsiMode::Strict, rscroll_char: None, word_wrap: false,
68 left_col: 0,
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum TrueColor {
77 Always,
78 Never,
79 #[default]
81 Auto,
82}
83
84impl TrueColor {
85 pub fn resolve(self) -> bool {
89 match self {
90 TrueColor::Always => true,
91 TrueColor::Never => false,
92 TrueColor::Auto => matches!(
93 std::env::var("COLORTERM").ok().as_deref(),
94 Some("truecolor") | Some("24bit"),
95 ),
96 }
97 }
98}
99
100pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
103 if r == g && g == b {
104 if r < 8 { return 16; }
105 if r > 248 { return 231; }
106 return 232 + ((r as u16 - 8) * 24 / 240) as u8;
107 }
108 let q = |c: u8| -> u8 {
109 if c < 48 { 0 }
110 else if c < 115 { 1 }
111 else { ((c as u16 - 35) / 40) as u8 }
112 };
113 16 + 36 * q(r) + 6 * q(g) + q(b)
114}
115
116fn decode_cluster(bytes: &[u8], i: usize) -> Option<(&str, usize)> {
120 let max = (i + 4).min(bytes.len());
128 let mut end = i;
129 for try_end in (i + 1)..=max {
130 if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
131 end = try_end;
132 break;
133 }
134 }
135 if end == i {
136 return None;
137 }
138
139 let mut probe_end = end;
144 loop {
145 let probe_max = (probe_end + 4).min(bytes.len());
147 let mut next_end = probe_end;
148 for try_end in (probe_end + 1)..=probe_max {
149 if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
150 next_end = try_end;
151 break;
152 }
153 }
154 if next_end == probe_end {
155 break;
156 }
157 let candidate = std::str::from_utf8(&bytes[i..next_end]).unwrap();
158 let cluster_count = candidate.graphemes(true).count();
159 if cluster_count > 1 {
160 break;
162 }
163 probe_end = next_end;
164 }
165
166 Some((std::str::from_utf8(&bytes[i..probe_end]).unwrap(), probe_end - i))
167}
168
169fn prefilter(
176 bytes: &[u8],
177 mode: AnsiMode,
178 state: Option<&mut RenderState>,
179) -> Vec<(u8, crate::ansi::Style, Option<Arc<str>>)> {
180 match mode {
181 AnsiMode::Strict | AnsiMode::Raw => {
182 bytes
185 .iter()
186 .map(|&b| (b, crate::ansi::Style::default(), None))
187 .collect()
188 }
189 AnsiMode::Interpret => {
190 use crate::ansi::ParseStep;
191 let mut tmp;
193 let st: &mut RenderState = match state {
194 Some(s) => s,
195 None => {
196 tmp = RenderState::default();
197 &mut tmp
198 }
199 };
200 let mut out = Vec::with_capacity(bytes.len());
201 for &b in bytes {
202 let step =
203 crate::ansi::step(&mut st.parse, &mut st.style, &mut st.hyperlink, b);
204 if let ParseStep::Printable(pb) = step {
205 let hl = st.hyperlink.as_deref().map(Arc::from);
206 out.push((pb, st.style, hl));
207 }
208 }
209 out
210 }
211 }
212}
213
214pub fn render_line(
215 bytes: &[u8],
216 opts: &RenderOpts,
217 state: Option<&mut RenderState>,
218) -> Vec<Vec<Cell>> {
219 let cols = opts.cols as usize;
220 let mut rows: Vec<Vec<Cell>> = Vec::new();
221 let mut current: Vec<Cell> = Vec::with_capacity(cols);
222
223 let filtered = prefilter(bytes, opts.mode, state);
225
226 let mut to_skip = if opts.wrap { 0 } else { opts.left_col };
228
229 fn push(current: &mut Vec<Cell>, rows: &mut Vec<Vec<Cell>>, cell: Cell, opts: &RenderOpts, to_skip: &mut usize) -> bool {
232 if *to_skip > 0 {
233 *to_skip -= 1; return false;
235 }
236 if current.len() >= opts.cols as usize {
237 if opts.wrap {
238 let mut full = std::mem::replace(current, Vec::with_capacity(opts.cols as usize));
239 if opts.word_wrap {
244 if let Some(ws_idx) = (0..full.len()).rev().find(|&i| matches!(
245 full[i],
246 Cell::Char { ch, .. } if ch == ' ' || ch == '\t'
247 )) {
248 let carry: Vec<Cell> = full.drain((ws_idx + 1)..).collect();
251 *current = carry;
252 }
253 }
254 while full.len() < opts.cols as usize { full.push(Cell::Empty); }
255 rows.push(full);
256 } else {
257 return true;
258 }
259 }
260 current.push(cell);
261 false
262 }
263
264 fn push_str(
265 current: &mut Vec<Cell>,
266 rows: &mut Vec<Vec<Cell>>,
267 s: &str,
268 style: crate::ansi::Style,
269 hyperlink: Option<Arc<str>>,
270 opts: &RenderOpts,
271 to_skip: &mut usize,
272 ) -> bool {
273 let mut overflowed = false;
274 for c in s.chars() {
275 overflowed |= push(
276 current,
277 rows,
278 Cell::Char { ch: c, width: 1, style, hyperlink: hyperlink.clone() },
279 opts,
280 to_skip,
281 );
282 }
283 overflowed
284 }
285
286 #[allow(clippy::too_many_arguments)]
287 fn push_wide(
288 current: &mut Vec<Cell>,
289 rows: &mut Vec<Vec<Cell>>,
290 ch: char,
291 width: u8,
292 style: crate::ansi::Style,
293 hyperlink: Option<Arc<str>>,
294 opts: &RenderOpts,
295 to_skip: &mut usize,
296 ) -> bool {
297 let cols = opts.cols as usize;
298 let w = width as usize;
299 if *to_skip >= w {
300 *to_skip -= w; return false;
302 }
303 if *to_skip > 0 {
304 let visible = w - *to_skip;
306 *to_skip = 0;
307 let mut of = false;
308 for _ in 0..visible {
309 of |= push(current, rows, Cell::Char { ch: ' ', width: 1, style, hyperlink: hyperlink.clone() }, opts, to_skip);
310 }
311 return of;
312 }
313 if current.len() + w > cols {
315 if opts.wrap {
316 let mut full = std::mem::replace(current, Vec::with_capacity(cols));
317 if opts.word_wrap {
322 if let Some(ws_idx) = (0..full.len()).rev().find(|&i| matches!(
323 full[i],
324 Cell::Char { ch, .. } if ch == ' ' || ch == '\t'
325 )) {
326 let carry: Vec<Cell> = full.drain((ws_idx + 1)..).collect();
327 *current = carry;
328 }
329 }
330 while full.len() < cols { full.push(Cell::Empty); }
331 rows.push(full);
332 } else {
333 return true; }
335 }
336 current.push(Cell::Char { ch, width, style, hyperlink });
337 for _ in 1..width {
338 current.push(Cell::Continuation);
339 }
340 false
341 }
342
343 let mut overflowed = false;
346 let mut i = 0;
347 while i < filtered.len() {
348 let (b, style, hyperlink) = filtered[i].clone();
349 if b == b'\t' {
350 let stop = opts.tab_width.max(1) as usize;
351 let skipped_so_far = if opts.wrap { 0 } else { opts.left_col - to_skip };
356 let cur_col = current.len() + skipped_so_far;
357 let next_stop = ((cur_col / stop) + 1) * stop;
358 for _ in cur_col..next_stop {
360 overflowed |= push(
361 &mut current,
362 &mut rows,
363 Cell::Char { ch: ' ', width: 1, style, hyperlink: hyperlink.clone() },
364 opts,
365 &mut to_skip,
366 );
367 }
368 i += 1;
369 } else if b == b'\n' {
370 i += 1;
371 } else if b < 0x20 || b == 0x7F {
372 let printable = if b == 0x7F { '?' } else { (b ^ 0x40) as char };
373 overflowed |= push(
374 &mut current,
375 &mut rows,
376 Cell::Char { ch: '^', width: 1, style, hyperlink: hyperlink.clone() },
377 opts,
378 &mut to_skip,
379 );
380 overflowed |= push(
381 &mut current,
382 &mut rows,
383 Cell::Char { ch: printable, width: 1, style, hyperlink },
384 opts,
385 &mut to_skip,
386 );
387 i += 1;
388 } else {
389 let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
392 match decode_cluster(&raw_bytes, 0) {
393 Some((cluster, consumed)) => {
394 let w = UnicodeWidthStr::width(cluster) as u8;
395 let base_char = cluster.chars().next().unwrap_or('\u{FFFD}');
396 if w == 0 {
397 overflowed |= push(
399 &mut current,
400 &mut rows,
401 Cell::Char {
402 ch: '\u{FFFD}',
403 width: 1,
404 style,
405 hyperlink,
406 },
407 opts,
408 &mut to_skip,
409 );
410 } else {
411 overflowed |= push_wide(&mut current, &mut rows, base_char, w, style, hyperlink, opts, &mut to_skip);
412 }
413 i += consumed;
414 }
415 None => {
416 let s = format!("<{:02X}>", b);
418 overflowed |= push_str(&mut current, &mut rows, &s, style, hyperlink, opts, &mut to_skip);
419 i += 1;
420 }
421 }
422 }
423 }
424
425 while current.len() < cols {
426 current.push(Cell::Empty);
427 }
428
429 if !opts.wrap && overflowed && cols > 0 {
433 if let Some(marker) = opts.rscroll_char {
434 current[cols - 1] = Cell::Char {
435 ch: marker,
436 width: 1,
437 style: crate::ansi::Style { dim: true, ..Default::default() },
438 hyperlink: None,
439 };
440 }
441 }
442
443 rows.push(current);
444 rows
445}
446
447pub fn display_width(bytes: &[u8], opts: &RenderOpts) -> usize {
451 let filtered = prefilter(bytes, opts.mode, None);
452 let stop = opts.tab_width.max(1) as usize;
453 let mut col = 0usize;
454 let mut i = 0;
455 while i < filtered.len() {
456 let (b, _, _) = &filtered[i];
457 if *b == b'\t' {
458 col = ((col / stop) + 1) * stop;
459 i += 1;
460 continue;
461 }
462 if *b == b'\n' {
463 i += 1;
464 continue;
465 }
466 if *b < 0x20 || *b == 0x7F {
467 col += 2;
469 i += 1;
470 continue;
471 }
472 let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
473 match decode_cluster(&raw_bytes, 0) {
474 Some((cluster, consumed)) => {
475 let w = UnicodeWidthStr::width(cluster);
476 col += if w == 0 { 1 } else { w }; i += consumed;
478 }
479 None => {
480 col += 4;
482 i += 1;
483 }
484 }
485 }
486 col
487}
488
489pub fn count_rows(
490 bytes: &[u8],
491 opts: &RenderOpts,
492 state: Option<&mut RenderState>,
493) -> usize {
494 if !opts.wrap {
495 return 1;
496 }
497 let cols = opts.cols.max(1) as usize;
498 let mut col = 0usize;
499 let mut rows = 1usize;
500
501 let bump = |w: usize, col: &mut usize, rows: &mut usize| {
502 if *col + w > cols {
503 *rows += 1;
504 *col = 0;
505 }
506 *col += w;
507 };
508
509 let filtered = prefilter(bytes, opts.mode, state);
511
512 let mut i = 0;
513 while i < filtered.len() {
514 let (b, _, _) = filtered[i];
515 if b == b'\t' {
516 let stop = opts.tab_width.max(1) as usize;
517 let next_stop = ((col / stop) + 1) * stop;
518 let advance = next_stop - col;
519 for _ in 0..advance {
521 bump(1, &mut col, &mut rows);
522 }
523 i += 1;
524 } else if b == b'\n' {
525 i += 1;
526 } else if b < 0x20 || b == 0x7F {
527 bump(1, &mut col, &mut rows); bump(1, &mut col, &mut rows); i += 1;
530 } else {
531 let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
532 match decode_cluster(&raw_bytes, 0) {
533 Some((cluster, consumed)) => {
534 let w = UnicodeWidthStr::width(cluster);
535 let w = if w == 0 { 1 } else { w };
536 bump(w, &mut col, &mut rows);
537 i += consumed;
538 }
539 None => {
540 for _ in 0..4 { bump(1, &mut col, &mut rows); }
542 i += 1;
543 }
544 }
545 }
546 }
547 rows
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn rgb_to_256_pure_corners_map_to_palette_extremes() {
556 assert_eq!(rgb_to_256(0, 0, 0), 16);
557 assert_eq!(rgb_to_256(255, 255, 255), 231);
558 }
559
560 #[test]
561 fn rgb_to_256_mid_gray_lands_in_grayscale_ramp() {
562 let n = rgb_to_256(128, 128, 128);
563 assert!((232..=255).contains(&n), "expected grayscale slot 232..=255, got {n}");
564 }
565
566 #[test]
567 fn rgb_to_256_pure_rgb_lands_in_cube_extremes() {
568 assert_eq!(rgb_to_256(255, 0, 0), 196);
569 assert_eq!(rgb_to_256(0, 255, 0), 46);
570 assert_eq!(rgb_to_256(0, 0, 255), 21);
571 }
572
573 #[test]
574 fn rgb_to_256_low_channel_quantizes_to_zero() {
575 assert_eq!(rgb_to_256(40, 200, 0), 40);
577 }
578
579 #[test]
580 fn rgb_to_256_near_black_gray_is_palette_black() {
581 assert_eq!(rgb_to_256(5, 5, 5), 16);
582 }
583
584 #[test]
585 fn rgb_to_256_near_white_gray_is_palette_white() {
586 assert_eq!(rgb_to_256(250, 250, 250), 231);
587 }
588
589 #[test]
590 fn truecolor_always_resolves_true_regardless_of_env() {
591 assert!(TrueColor::Always.resolve());
592 }
593
594 #[test]
595 fn truecolor_never_resolves_false_regardless_of_env() {
596 assert!(!TrueColor::Never.resolve());
597 }
598
599 #[test]
600 fn rscroll_marker_appears_on_chopped_row() {
601 let mut o = opts(5, false); o.rscroll_char = Some('>');
603 let rows = render_line(b"abcdefgh", &o, None);
604 assert_eq!(rows.len(), 1);
605 match &rows[0][4] {
606 Cell::Char { ch, .. } => assert_eq!(*ch, '>'),
607 other => panic!("expected `>` marker, got {other:?}"),
608 }
609 }
610
611 #[test]
612 fn rscroll_marker_absent_on_fitting_row() {
613 let mut o = opts(10, false);
614 o.rscroll_char = Some('>');
615 let rows = render_line(b"abc", &o, None);
616 match &rows[0][2] {
617 Cell::Char { ch, .. } => assert_eq!(*ch, 'c'),
618 other => panic!("expected content `c`, got {other:?}"),
619 }
620 }
621
622 #[test]
623 fn rscroll_marker_disabled_emits_normal_chop() {
624 let mut o = opts(5, false);
625 o.rscroll_char = None;
626 let rows = render_line(b"abcdefgh", &o, None);
627 match &rows[0][4] {
628 Cell::Char { ch, .. } => assert_eq!(*ch, 'e'),
629 other => panic!("expected last fitting char, got {other:?}"),
630 }
631 }
632
633 #[test]
634 fn word_wrap_breaks_on_whitespace() {
635 let mut o = opts(8, true);
636 o.word_wrap = true;
637 let rows = render_line(b"the quick brown fox", &o, None);
638 let r0: String = rows[0].iter().filter_map(|c| match c {
640 Cell::Char { ch, .. } => Some(*ch),
641 _ => None,
642 }).collect();
643 assert_eq!(r0.trim_end(), "the");
644 }
645
646 #[test]
647 fn word_wrap_falls_back_when_no_whitespace_fits() {
648 let mut o = opts(5, true);
649 o.word_wrap = true;
650 let rows = render_line(b"antidisestablishment", &o, None);
651 let r0: String = rows[0].iter().filter_map(|c| match c {
652 Cell::Char { ch, .. } => Some(*ch),
653 _ => None,
654 }).collect();
655 assert_eq!(r0.trim_end(), "antid");
657 }
658
659 #[test]
660 fn word_wrap_off_breaks_mid_word() {
661 let mut o = opts(8, true);
662 o.word_wrap = false;
663 let rows = render_line(b"the quick brown fox", &o, None);
664 let r0: String = rows[0].iter().filter_map(|c| match c {
665 Cell::Char { ch, .. } => Some(*ch),
666 _ => None,
667 }).collect();
668 assert_eq!(r0.trim_end(), "the quic");
670 }
671
672 #[test]
673 fn rscroll_marker_absent_in_wrap_mode() {
674 let mut o = opts(5, true);
675 o.rscroll_char = Some('>');
676 let rows = render_line(b"abcdefgh", &o, None);
677 assert!(rows.len() > 1);
679 for row in &rows {
680 for cell in row {
681 if let Cell::Char { ch, .. } = cell {
682 assert_ne!(*ch, '>', "rscroll marker leaked into wrap mode");
683 }
684 }
685 }
686 }
687
688 fn opts(cols: u16, wrap: bool) -> RenderOpts {
689 RenderOpts { tab_width: 8, wrap, cols, mode: AnsiMode::Strict, rscroll_char: None, word_wrap: false, left_col: 0 }
690 }
691
692 fn ch(c: char) -> Cell {
693 Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None }
694 }
695
696 #[test]
697 fn ascii_short_line_pads_to_cols() {
698 let rows = render_line(b"hi", &opts(5, true), None);
699 assert_eq!(rows.len(), 1);
700 assert_eq!(rows[0], vec![ch('h'), ch('i'), Cell::Empty, Cell::Empty, Cell::Empty]);
701 }
702
703 #[test]
704 fn ascii_exact_width() {
705 let rows = render_line(b"hello", &opts(5, true), None);
706 assert_eq!(rows.len(), 1);
707 assert_eq!(rows[0], vec![ch('h'), ch('e'), ch('l'), ch('l'), ch('o')]);
708 }
709
710 #[test]
711 fn empty_input_yields_one_empty_row() {
712 let rows = render_line(b"", &opts(3, true), None);
713 assert_eq!(rows, vec![vec![Cell::Empty, Cell::Empty, Cell::Empty]]);
714 }
715
716 #[test]
717 fn tab_at_col_zero_expands_to_eight() {
718 let rows = render_line(b"\tx", &opts(20, true), None);
719 for (i, cell) in rows[0].iter().take(8).enumerate() {
721 assert_eq!(*cell, ch(' '), "col {i} should be space");
722 }
723 assert_eq!(rows[0][8], ch('x'));
724 }
725
726 #[test]
727 fn tab_at_col_three_advances_to_next_stop() {
728 let rows = render_line(b"abc\tx", &opts(20, true), None);
730 assert_eq!(rows[0][0], ch('a'));
731 assert_eq!(rows[0][2], ch('c'));
732 for cell in rows[0].iter().skip(3).take(5) {
733 assert_eq!(*cell, ch(' '));
734 }
735 assert_eq!(rows[0][8], ch('x'));
736 }
737
738 #[test]
739 fn tab_at_col_eight_advances_to_sixteen() {
740 let mut input = vec![b'a'; 8];
741 input.push(b'\t');
742 input.push(b'x');
743 let rows = render_line(&input, &opts(20, true), None);
744 for cell in rows[0].iter().skip(8).take(8) {
745 assert_eq!(*cell, ch(' '));
746 }
747 assert_eq!(rows[0][16], ch('x'));
748 }
749
750 #[test]
751 fn null_renders_as_caret_at() {
752 let rows = render_line(b"\0", &opts(5, true), None);
753 assert_eq!(rows[0][0], ch('^'));
754 assert_eq!(rows[0][1], ch('@'));
755 }
756
757 #[test]
758 fn esc_renders_as_caret_lbracket() {
759 let rows = render_line(b"\x1b", &opts(5, true), None);
760 assert_eq!(rows[0][0], ch('^'));
761 assert_eq!(rows[0][1], ch('['));
762 }
763
764 #[test]
765 fn del_renders_as_caret_question() {
766 let rows = render_line(b"\x7f", &opts(5, true), None);
767 assert_eq!(rows[0][0], ch('^'));
768 assert_eq!(rows[0][1], ch('?'));
769 }
770
771 #[test]
772 fn invalid_utf8_byte_renders_as_angle_hex() {
773 let rows = render_line(&[0xFF], &opts(8, true), None);
774 assert_eq!(rows[0][0], ch('<'));
775 assert_eq!(rows[0][1], ch('F'));
776 assert_eq!(rows[0][2], ch('F'));
777 assert_eq!(rows[0][3], ch('>'));
778 }
779
780 #[test]
781 fn partial_multibyte_each_byte_renders_separately() {
782 let rows = render_line(&[0xC3], &opts(8, true), None);
784 assert_eq!(rows[0][0], ch('<'));
785 assert_eq!(rows[0][1], ch('C'));
786 assert_eq!(rows[0][2], ch('3'));
787 assert_eq!(rows[0][3], ch('>'));
788 }
789
790 #[test]
791 fn single_byte_utf8_e_acute() {
792 let rows = render_line("é".as_bytes(), &opts(5, true), None);
793 assert_eq!(
794 rows[0][0],
795 Cell::Char { ch: 'é', width: 1, style: crate::ansi::Style::default(), hyperlink: None }
796 );
797 }
798
799 #[test]
800 fn cjk_char_takes_two_columns() {
801 let rows = render_line("日".as_bytes(), &opts(5, true), None);
803 assert_eq!(
804 rows[0][0],
805 Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
806 );
807 assert_eq!(rows[0][1], Cell::Continuation);
808 assert_eq!(rows[0][2], Cell::Empty);
809 }
810
811 #[test]
812 fn emoji_takes_two_columns() {
813 let rows = render_line("🦀".as_bytes(), &opts(5, true), None);
814 assert!(matches!(rows[0][0], Cell::Char { width: 2, .. }));
816 assert_eq!(rows[0][1], Cell::Continuation);
817 }
818
819 #[test]
820 fn combining_mark_folds_into_prior_cell() {
821 let rows = render_line("e\u{0301}".as_bytes(), &opts(5, true), None);
823 assert!(matches!(rows[0][0], Cell::Char { width: 1, .. }));
825 assert_eq!(rows[0][1], Cell::Empty);
826 }
827
828 #[test]
829 fn wrap_long_line_into_multiple_rows() {
830 let rows = render_line(b"abcdefghij", &opts(4, true), None);
831 assert_eq!(rows.len(), 3);
832 assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
833 assert_eq!(rows[1], vec![ch('e'), ch('f'), ch('g'), ch('h')]);
834 assert_eq!(rows[2], vec![ch('i'), ch('j'), Cell::Empty, Cell::Empty]);
835 }
836
837 #[test]
838 fn chop_long_line_truncates() {
839 let rows = render_line(b"abcdefghij", &opts(4, false), None);
840 assert_eq!(rows.len(), 1);
841 assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
842 }
843
844 #[test]
845 fn wide_char_at_boundary_pushed_to_next_row() {
846 let rows = render_line("ab日".as_bytes(), &opts(3, true), None);
849 assert_eq!(rows.len(), 2);
850 assert_eq!(rows[0], vec![ch('a'), ch('b'), Cell::Empty]);
851 assert_eq!(
852 rows[1][0],
853 Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
854 );
855 assert_eq!(rows[1][1], Cell::Continuation);
856 assert_eq!(rows[1][2], Cell::Empty);
857 }
858
859 #[test]
860 fn count_rows_matches_render_line_for_short() {
861 let o = opts(80, true);
862 let bytes = b"hello world";
863 assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
864 }
865
866 #[test]
867 fn count_rows_matches_render_line_for_long_wrap() {
868 let o = opts(4, true);
869 let bytes = b"abcdefghij";
870 assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
871 }
872
873 #[test]
874 fn count_rows_chop_is_one() {
875 let o = opts(4, false);
876 let bytes = b"abcdefghij";
877 assert_eq!(count_rows(bytes, &o, None), 1);
878 }
879
880 #[test]
881 fn count_rows_handles_wide_char() {
882 let o = opts(3, true);
883 let bytes = "ab日".as_bytes();
884 assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
885 }
886
887 fn interpret_opts() -> RenderOpts {
890 RenderOpts { mode: AnsiMode::Interpret, ..Default::default() }
891 }
892
893 #[test]
894 fn interpret_red_text() {
895 let mut state = RenderState::default();
896 let rows = render_line(b"\x1b[31mhi", &interpret_opts(), Some(&mut state));
897 let cells: Vec<&Cell> =
898 rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
899 assert_eq!(cells.len(), 2);
900 for c in cells {
901 if let Cell::Char { style, .. } = c {
902 assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
903 }
904 }
905 }
906
907 #[test]
908 fn interpret_truecolor() {
909 let mut state = RenderState::default();
910 let rows =
911 render_line(b"\x1b[38;2;255;0;0mfoo", &interpret_opts(), Some(&mut state));
912 let cells: Vec<&Cell> =
913 rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
914 for c in cells {
915 if let Cell::Char { style, .. } = c {
916 assert_eq!(style.fg, Some(crate::ansi::Color::Rgb(255, 0, 0)));
917 }
918 }
919 }
920
921 #[test]
922 fn interpret_wide_char_carries_color() {
923 let mut state = RenderState::default();
924 let rows =
925 render_line("\x1b[31m日".as_bytes(), &interpret_opts(), Some(&mut state));
926 let jp_cell = rows.iter().flatten().find_map(|c| match c {
927 Cell::Char { ch: '日', style, width, .. } => Some((style, *width)),
928 _ => None,
929 });
930 let (style, width) = jp_cell.expect("expected 日 cell");
931 assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
932 assert_eq!(width, 2);
933 }
934
935 #[test]
936 fn interpret_state_persists_across_calls() {
937 let mut state = RenderState::default();
938 let _ = render_line(b"\x1b[31mline1", &interpret_opts(), Some(&mut state));
939 let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
940 let l_cell = rows.iter().flatten().find_map(|c| match c {
941 Cell::Char { ch: 'l', style, .. } => Some(style),
942 _ => None,
943 });
944 assert_eq!(
945 l_cell.expect("expected l cell").fg,
946 Some(crate::ansi::Color::Ansi(1))
947 );
948 }
949
950 #[test]
951 fn interpret_reset_clears_state() {
952 let mut state = RenderState::default();
953 let _ =
954 render_line(b"\x1b[31mline1\x1b[0m", &interpret_opts(), Some(&mut state));
955 let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
956 let l_cell = rows.iter().flatten().find_map(|c| match c {
957 Cell::Char { ch: 'l', style, .. } => Some(style),
958 _ => None,
959 });
960 assert_eq!(l_cell.expect("expected l cell"), &crate::ansi::Style::default());
961 }
962
963 #[test]
964 fn interpret_non_sgr_csi_is_zero_width() {
965 let mut state = RenderState::default();
966 let rows = render_line(b"\x1b[2Jdata", &interpret_opts(), Some(&mut state));
967 let chars: String = rows
968 .iter()
969 .flatten()
970 .filter_map(|c| match c {
971 Cell::Char { ch, .. } => Some(*ch),
972 _ => None,
973 })
974 .collect();
975 assert_eq!(chars, "data");
976 }
977
978 #[test]
979 fn strict_mode_esc_still_renders_as_caret_lbracket() {
980 let rows = render_line(b"\x1b[31mhi", &RenderOpts::default(), None);
982 let chars: String = rows
983 .iter()
984 .flatten()
985 .filter_map(|c| match c {
986 Cell::Char { ch, .. } => Some(*ch),
987 _ => None,
988 })
989 .collect();
990 assert!(chars.starts_with("^["), "got: {chars:?}");
991 }
992
993 #[test]
994 fn osc8_hyperlink_attached_to_cells() {
995 let mut state = RenderState::default();
996 let rows = render_line(
997 b"\x1b]8;;https://example.com\x07click\x1b]8;;\x07",
998 &interpret_opts(),
999 Some(&mut state),
1000 );
1001 let click_cell = rows.iter().flatten().find_map(|c| match c {
1002 Cell::Char { ch: 'c', hyperlink, .. } => Some(hyperlink.clone()),
1003 _ => None,
1004 });
1005 let link = click_cell.expect("expected c cell").expect("expected hyperlink");
1006 assert_eq!(link.as_ref(), "https://example.com");
1007 }
1008
1009 #[test]
1010 fn left_col_skips_leading_columns_in_chop() {
1011 let opts = RenderOpts { wrap: false, cols: 4, left_col: 3, ..Default::default() };
1012 let rows = render_line(b"abcdefgh", &opts, None);
1013 assert_eq!(rows.len(), 1);
1014 let s: String = rows[0].iter().filter_map(|c| match c {
1015 Cell::Char { ch, .. } => Some(*ch), _ => None }).collect();
1016 assert_eq!(s, "defg");
1017 }
1018
1019 #[test]
1020 fn left_col_zero_is_unchanged() {
1021 let opts = RenderOpts { wrap: false, cols: 4, left_col: 0, ..Default::default() };
1022 let rows = render_line(b"abcdefgh", &opts, None);
1023 let s: String = rows[0].iter().filter_map(|c| match c {
1024 Cell::Char { ch, .. } => Some(*ch), _ => None }).collect();
1025 assert_eq!(s, "abcd");
1026 }
1027
1028 #[test]
1029 fn left_col_ignored_in_wrap_mode() {
1030 let opts = RenderOpts { wrap: true, cols: 4, left_col: 3, ..Default::default() };
1031 let rows = render_line(b"abcdefgh", &opts, None);
1032 let first: String = rows[0].iter().filter_map(|c| match c {
1033 Cell::Char { ch, .. } => Some(*ch), _ => None }).collect();
1034 assert_eq!(first, "abcd");
1035 }
1036
1037 #[test]
1038 fn left_col_past_end_is_blank() {
1039 let opts = RenderOpts { wrap: false, cols: 4, left_col: 20, ..Default::default() };
1040 let rows = render_line(b"abc", &opts, None);
1041 assert_eq!(rows.len(), 1);
1042 assert!(rows[0].iter().all(|c| matches!(c, Cell::Empty)));
1043 }
1044
1045 #[test]
1046 fn left_col_tab_expansion_across_boundary() {
1047 let opts = RenderOpts { wrap: false, cols: 4, left_col: 2, tab_width: 4, ..Default::default() };
1048 let rows = render_line(b"\tX", &opts, None);
1049 let cells = &rows[0];
1050 assert!(matches!(cells[0], Cell::Char { ch: ' ', .. }));
1051 assert!(matches!(cells[1], Cell::Char { ch: ' ', .. }));
1052 assert!(matches!(cells[2], Cell::Char { ch: 'X', .. }));
1053 }
1054
1055 #[test]
1056 fn left_col_does_not_change_count_rows() {
1057 let opts = RenderOpts { wrap: false, cols: 4, left_col: 3, ..Default::default() };
1058 assert_eq!(count_rows(b"abcdefgh", &opts, None), 1);
1059 }
1060
1061 #[test]
1062 fn display_width_counts_tabs_and_ascii() {
1063 let opts = RenderOpts { tab_width: 4, ..Default::default() };
1064 assert_eq!(display_width(b"ab", &opts), 2);
1065 assert_eq!(display_width(b"\tab", &opts), 6);
1066 }
1067
1068 #[test]
1069 fn display_width_agrees_with_rendered_columns() {
1070 let line = "a\tÅ中b".as_bytes();
1074 let opts = RenderOpts { wrap: false, cols: 1000, tab_width: 4, ..Default::default() };
1075 let rows = render_line(line, &opts, None);
1076 let cols_used = rows[0].iter().take_while(|c| !matches!(c, Cell::Empty)).count();
1077 assert_eq!(display_width(line, &opts), cols_used);
1078 }
1079}