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}
58
59impl Default for RenderOpts {
60 fn default() -> Self {
61 Self {
62 tab_width: 8, wrap: true, cols: 80,
63 mode: AnsiMode::Strict, rscroll_char: None, word_wrap: false,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum TrueColor {
72 Always,
73 Never,
74 Auto,
76}
77
78impl Default for TrueColor {
79 fn default() -> Self { TrueColor::Auto }
80}
81
82impl TrueColor {
83 pub fn resolve(self) -> bool {
87 match self {
88 TrueColor::Always => true,
89 TrueColor::Never => false,
90 TrueColor::Auto => matches!(
91 std::env::var("COLORTERM").ok().as_deref(),
92 Some("truecolor") | Some("24bit"),
93 ),
94 }
95 }
96}
97
98pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
101 if r == g && g == b {
102 if r < 8 { return 16; }
103 if r > 248 { return 231; }
104 return 232 + ((r as u16 - 8) * 24 / 240) as u8;
105 }
106 let q = |c: u8| -> u8 {
107 if c < 48 { 0 }
108 else if c < 115 { 1 }
109 else { ((c as u16 - 35) / 40) as u8 }
110 };
111 16 + 36 * q(r) + 6 * q(g) + q(b)
112}
113
114fn decode_cluster(bytes: &[u8], i: usize) -> Option<(&str, usize)> {
118 let max = (i + 4).min(bytes.len());
126 let mut end = i;
127 for try_end in (i + 1)..=max {
128 if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
129 end = try_end;
130 break;
131 }
132 }
133 if end == i {
134 return None;
135 }
136
137 let mut probe_end = end;
142 loop {
143 let probe_max = (probe_end + 4).min(bytes.len());
145 let mut next_end = probe_end;
146 for try_end in (probe_end + 1)..=probe_max {
147 if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
148 next_end = try_end;
149 break;
150 }
151 }
152 if next_end == probe_end {
153 break;
154 }
155 let candidate = std::str::from_utf8(&bytes[i..next_end]).unwrap();
156 let cluster_count = candidate.graphemes(true).count();
157 if cluster_count > 1 {
158 break;
160 }
161 probe_end = next_end;
162 }
163
164 Some((std::str::from_utf8(&bytes[i..probe_end]).unwrap(), probe_end - i))
165}
166
167fn prefilter(
174 bytes: &[u8],
175 mode: AnsiMode,
176 state: Option<&mut RenderState>,
177) -> Vec<(u8, crate::ansi::Style, Option<Arc<str>>)> {
178 match mode {
179 AnsiMode::Strict | AnsiMode::Raw => {
180 bytes
183 .iter()
184 .map(|&b| (b, crate::ansi::Style::default(), None))
185 .collect()
186 }
187 AnsiMode::Interpret => {
188 use crate::ansi::ParseStep;
189 let mut tmp;
191 let st: &mut RenderState = match state {
192 Some(s) => s,
193 None => {
194 tmp = RenderState::default();
195 &mut tmp
196 }
197 };
198 let mut out = Vec::with_capacity(bytes.len());
199 for &b in bytes {
200 let step =
201 crate::ansi::step(&mut st.parse, &mut st.style, &mut st.hyperlink, b);
202 if let ParseStep::Printable(pb) = step {
203 let hl = st.hyperlink.as_deref().map(Arc::from);
204 out.push((pb, st.style, hl));
205 }
206 }
207 out
208 }
209 }
210}
211
212pub fn render_line(
213 bytes: &[u8],
214 opts: &RenderOpts,
215 state: Option<&mut RenderState>,
216) -> Vec<Vec<Cell>> {
217 let cols = opts.cols as usize;
218 let mut rows: Vec<Vec<Cell>> = Vec::new();
219 let mut current: Vec<Cell> = Vec::with_capacity(cols);
220
221 let filtered = prefilter(bytes, opts.mode, state);
223
224 fn push(current: &mut Vec<Cell>, rows: &mut Vec<Vec<Cell>>, cell: Cell, opts: &RenderOpts) -> bool {
227 if current.len() >= opts.cols as usize {
228 if opts.wrap {
229 let mut full = std::mem::replace(current, Vec::with_capacity(opts.cols as usize));
230 if opts.word_wrap {
235 if let Some(ws_idx) = (0..full.len()).rev().find(|&i| matches!(
236 full[i],
237 Cell::Char { ch, .. } if ch == ' ' || ch == '\t'
238 )) {
239 let carry: Vec<Cell> = full.drain((ws_idx + 1)..).collect();
242 *current = carry;
243 }
244 }
245 while full.len() < opts.cols as usize { full.push(Cell::Empty); }
246 rows.push(full);
247 } else {
248 return true;
249 }
250 }
251 current.push(cell);
252 false
253 }
254
255 fn push_str(
256 current: &mut Vec<Cell>,
257 rows: &mut Vec<Vec<Cell>>,
258 s: &str,
259 style: crate::ansi::Style,
260 hyperlink: Option<Arc<str>>,
261 opts: &RenderOpts,
262 ) -> bool {
263 let mut overflowed = false;
264 for c in s.chars() {
265 overflowed |= push(
266 current,
267 rows,
268 Cell::Char { ch: c, width: 1, style, hyperlink: hyperlink.clone() },
269 opts,
270 );
271 }
272 overflowed
273 }
274
275 fn push_wide(
276 current: &mut Vec<Cell>,
277 rows: &mut Vec<Vec<Cell>>,
278 ch: char,
279 width: u8,
280 style: crate::ansi::Style,
281 hyperlink: Option<Arc<str>>,
282 opts: &RenderOpts,
283 ) -> bool {
284 let cols = opts.cols as usize;
285 if current.len() + width as usize > cols {
287 if opts.wrap {
288 let mut full = std::mem::replace(current, Vec::with_capacity(cols));
289 if opts.word_wrap {
294 if let Some(ws_idx) = (0..full.len()).rev().find(|&i| matches!(
295 full[i],
296 Cell::Char { ch, .. } if ch == ' ' || ch == '\t'
297 )) {
298 let carry: Vec<Cell> = full.drain((ws_idx + 1)..).collect();
299 *current = carry;
300 }
301 }
302 while full.len() < cols { full.push(Cell::Empty); }
303 rows.push(full);
304 } else {
305 return true; }
307 }
308 current.push(Cell::Char { ch, width, style, hyperlink });
309 for _ in 1..width {
310 current.push(Cell::Continuation);
311 }
312 false
313 }
314
315 let mut overflowed = false;
318 let mut i = 0;
319 while i < filtered.len() {
320 let (b, style, hyperlink) = filtered[i].clone();
321 if b == b'\t' {
322 let stop = opts.tab_width.max(1) as usize;
323 let cur_col = current.len();
324 let next_stop = ((cur_col / stop) + 1) * stop;
325 for _ in cur_col..next_stop {
326 overflowed |= push(
327 &mut current,
328 &mut rows,
329 Cell::Char { ch: ' ', width: 1, style, hyperlink: hyperlink.clone() },
330 opts,
331 );
332 }
333 i += 1;
334 } else if b == b'\n' {
335 i += 1;
336 } else if b < 0x20 || b == 0x7F {
337 let printable = if b == 0x7F { '?' } else { (b ^ 0x40) as char };
338 overflowed |= push(
339 &mut current,
340 &mut rows,
341 Cell::Char { ch: '^', width: 1, style, hyperlink: hyperlink.clone() },
342 opts,
343 );
344 overflowed |= push(
345 &mut current,
346 &mut rows,
347 Cell::Char { ch: printable, width: 1, style, hyperlink },
348 opts,
349 );
350 i += 1;
351 } else {
352 let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
355 match decode_cluster(&raw_bytes, 0) {
356 Some((cluster, consumed)) => {
357 let w = UnicodeWidthStr::width(cluster) as u8;
358 let base_char = cluster.chars().next().unwrap_or('\u{FFFD}');
359 if w == 0 {
360 overflowed |= push(
362 &mut current,
363 &mut rows,
364 Cell::Char {
365 ch: '\u{FFFD}',
366 width: 1,
367 style,
368 hyperlink,
369 },
370 opts,
371 );
372 } else {
373 overflowed |= push_wide(&mut current, &mut rows, base_char, w, style, hyperlink, opts);
374 }
375 i += consumed;
376 }
377 None => {
378 let s = format!("<{:02X}>", b);
380 overflowed |= push_str(&mut current, &mut rows, &s, style, hyperlink, opts);
381 i += 1;
382 }
383 }
384 }
385 }
386
387 while current.len() < cols {
388 current.push(Cell::Empty);
389 }
390
391 if !opts.wrap && overflowed && cols > 0 {
395 if let Some(marker) = opts.rscroll_char {
396 current[cols - 1] = Cell::Char {
397 ch: marker,
398 width: 1,
399 style: crate::ansi::Style { dim: true, ..Default::default() },
400 hyperlink: None,
401 };
402 }
403 }
404
405 rows.push(current);
406 rows
407}
408
409pub fn count_rows(
410 bytes: &[u8],
411 opts: &RenderOpts,
412 state: Option<&mut RenderState>,
413) -> usize {
414 if !opts.wrap {
415 return 1;
416 }
417 let cols = opts.cols.max(1) as usize;
418 let mut col = 0usize;
419 let mut rows = 1usize;
420
421 let bump = |w: usize, col: &mut usize, rows: &mut usize| {
422 if *col + w > cols {
423 *rows += 1;
424 *col = 0;
425 }
426 *col += w;
427 };
428
429 let filtered = prefilter(bytes, opts.mode, state);
431
432 let mut i = 0;
433 while i < filtered.len() {
434 let (b, _, _) = filtered[i];
435 if b == b'\t' {
436 let stop = opts.tab_width.max(1) as usize;
437 let next_stop = ((col / stop) + 1) * stop;
438 let advance = next_stop - col;
439 for _ in 0..advance {
441 bump(1, &mut col, &mut rows);
442 }
443 i += 1;
444 } else if b == b'\n' {
445 i += 1;
446 } else if b < 0x20 || b == 0x7F {
447 bump(1, &mut col, &mut rows); bump(1, &mut col, &mut rows); i += 1;
450 } else {
451 let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
452 match decode_cluster(&raw_bytes, 0) {
453 Some((cluster, consumed)) => {
454 let w = UnicodeWidthStr::width(cluster);
455 let w = if w == 0 { 1 } else { w };
456 bump(w, &mut col, &mut rows);
457 i += consumed;
458 }
459 None => {
460 for _ in 0..4 { bump(1, &mut col, &mut rows); }
462 i += 1;
463 }
464 }
465 }
466 }
467 rows
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn rgb_to_256_pure_corners_map_to_palette_extremes() {
476 assert_eq!(rgb_to_256(0, 0, 0), 16);
477 assert_eq!(rgb_to_256(255, 255, 255), 231);
478 }
479
480 #[test]
481 fn rgb_to_256_mid_gray_lands_in_grayscale_ramp() {
482 let n = rgb_to_256(128, 128, 128);
483 assert!((232..=255).contains(&n), "expected grayscale slot 232..=255, got {n}");
484 }
485
486 #[test]
487 fn rgb_to_256_pure_rgb_lands_in_cube_extremes() {
488 assert_eq!(rgb_to_256(255, 0, 0), 196);
489 assert_eq!(rgb_to_256(0, 255, 0), 46);
490 assert_eq!(rgb_to_256(0, 0, 255), 21);
491 }
492
493 #[test]
494 fn rgb_to_256_low_channel_quantizes_to_zero() {
495 assert_eq!(rgb_to_256(40, 200, 0), 16 + 0 * 36 + 4 * 6 + 0);
496 }
497
498 #[test]
499 fn rgb_to_256_near_black_gray_is_palette_black() {
500 assert_eq!(rgb_to_256(5, 5, 5), 16);
501 }
502
503 #[test]
504 fn rgb_to_256_near_white_gray_is_palette_white() {
505 assert_eq!(rgb_to_256(250, 250, 250), 231);
506 }
507
508 #[test]
509 fn truecolor_always_resolves_true_regardless_of_env() {
510 assert!(TrueColor::Always.resolve());
511 }
512
513 #[test]
514 fn truecolor_never_resolves_false_regardless_of_env() {
515 assert!(!TrueColor::Never.resolve());
516 }
517
518 #[test]
519 fn rscroll_marker_appears_on_chopped_row() {
520 let mut o = opts(5, false); o.rscroll_char = Some('>');
522 let rows = render_line(b"abcdefgh", &o, None);
523 assert_eq!(rows.len(), 1);
524 match &rows[0][4] {
525 Cell::Char { ch, .. } => assert_eq!(*ch, '>'),
526 other => panic!("expected `>` marker, got {other:?}"),
527 }
528 }
529
530 #[test]
531 fn rscroll_marker_absent_on_fitting_row() {
532 let mut o = opts(10, false);
533 o.rscroll_char = Some('>');
534 let rows = render_line(b"abc", &o, None);
535 match &rows[0][2] {
536 Cell::Char { ch, .. } => assert_eq!(*ch, 'c'),
537 other => panic!("expected content `c`, got {other:?}"),
538 }
539 }
540
541 #[test]
542 fn rscroll_marker_disabled_emits_normal_chop() {
543 let mut o = opts(5, false);
544 o.rscroll_char = None;
545 let rows = render_line(b"abcdefgh", &o, None);
546 match &rows[0][4] {
547 Cell::Char { ch, .. } => assert_eq!(*ch, 'e'),
548 other => panic!("expected last fitting char, got {other:?}"),
549 }
550 }
551
552 #[test]
553 fn word_wrap_breaks_on_whitespace() {
554 let mut o = opts(8, true);
555 o.word_wrap = true;
556 let rows = render_line(b"the quick brown fox", &o, None);
557 let r0: String = rows[0].iter().filter_map(|c| match c {
559 Cell::Char { ch, .. } => Some(*ch),
560 _ => None,
561 }).collect();
562 assert_eq!(r0.trim_end(), "the");
563 }
564
565 #[test]
566 fn word_wrap_falls_back_when_no_whitespace_fits() {
567 let mut o = opts(5, true);
568 o.word_wrap = true;
569 let rows = render_line(b"antidisestablishment", &o, None);
570 let r0: String = rows[0].iter().filter_map(|c| match c {
571 Cell::Char { ch, .. } => Some(*ch),
572 _ => None,
573 }).collect();
574 assert_eq!(r0.trim_end(), "antid");
576 }
577
578 #[test]
579 fn word_wrap_off_breaks_mid_word() {
580 let mut o = opts(8, true);
581 o.word_wrap = false;
582 let rows = render_line(b"the quick brown fox", &o, None);
583 let r0: String = rows[0].iter().filter_map(|c| match c {
584 Cell::Char { ch, .. } => Some(*ch),
585 _ => None,
586 }).collect();
587 assert_eq!(r0.trim_end(), "the quic");
589 }
590
591 #[test]
592 fn rscroll_marker_absent_in_wrap_mode() {
593 let mut o = opts(5, true);
594 o.rscroll_char = Some('>');
595 let rows = render_line(b"abcdefgh", &o, None);
596 assert!(rows.len() > 1);
598 for row in &rows {
599 for cell in row {
600 if let Cell::Char { ch, .. } = cell {
601 assert_ne!(*ch, '>', "rscroll marker leaked into wrap mode");
602 }
603 }
604 }
605 }
606
607 fn opts(cols: u16, wrap: bool) -> RenderOpts {
608 RenderOpts { tab_width: 8, wrap, cols, mode: AnsiMode::Strict, rscroll_char: None, word_wrap: false }
609 }
610
611 fn ch(c: char) -> Cell {
612 Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None }
613 }
614
615 #[test]
616 fn ascii_short_line_pads_to_cols() {
617 let rows = render_line(b"hi", &opts(5, true), None);
618 assert_eq!(rows.len(), 1);
619 assert_eq!(rows[0], vec![ch('h'), ch('i'), Cell::Empty, Cell::Empty, Cell::Empty]);
620 }
621
622 #[test]
623 fn ascii_exact_width() {
624 let rows = render_line(b"hello", &opts(5, true), None);
625 assert_eq!(rows.len(), 1);
626 assert_eq!(rows[0], vec![ch('h'), ch('e'), ch('l'), ch('l'), ch('o')]);
627 }
628
629 #[test]
630 fn empty_input_yields_one_empty_row() {
631 let rows = render_line(b"", &opts(3, true), None);
632 assert_eq!(rows, vec![vec![Cell::Empty, Cell::Empty, Cell::Empty]]);
633 }
634
635 #[test]
636 fn tab_at_col_zero_expands_to_eight() {
637 let rows = render_line(b"\tx", &opts(20, true), None);
638 for (i, cell) in rows[0].iter().take(8).enumerate() {
640 assert_eq!(*cell, ch(' '), "col {i} should be space");
641 }
642 assert_eq!(rows[0][8], ch('x'));
643 }
644
645 #[test]
646 fn tab_at_col_three_advances_to_next_stop() {
647 let rows = render_line(b"abc\tx", &opts(20, true), None);
649 assert_eq!(rows[0][0], ch('a'));
650 assert_eq!(rows[0][2], ch('c'));
651 for cell in rows[0].iter().skip(3).take(5) {
652 assert_eq!(*cell, ch(' '));
653 }
654 assert_eq!(rows[0][8], ch('x'));
655 }
656
657 #[test]
658 fn tab_at_col_eight_advances_to_sixteen() {
659 let mut input = vec![b'a'; 8];
660 input.push(b'\t');
661 input.push(b'x');
662 let rows = render_line(&input, &opts(20, true), None);
663 for cell in rows[0].iter().skip(8).take(8) {
664 assert_eq!(*cell, ch(' '));
665 }
666 assert_eq!(rows[0][16], ch('x'));
667 }
668
669 #[test]
670 fn null_renders_as_caret_at() {
671 let rows = render_line(b"\0", &opts(5, true), None);
672 assert_eq!(rows[0][0], ch('^'));
673 assert_eq!(rows[0][1], ch('@'));
674 }
675
676 #[test]
677 fn esc_renders_as_caret_lbracket() {
678 let rows = render_line(b"\x1b", &opts(5, true), None);
679 assert_eq!(rows[0][0], ch('^'));
680 assert_eq!(rows[0][1], ch('['));
681 }
682
683 #[test]
684 fn del_renders_as_caret_question() {
685 let rows = render_line(b"\x7f", &opts(5, true), None);
686 assert_eq!(rows[0][0], ch('^'));
687 assert_eq!(rows[0][1], ch('?'));
688 }
689
690 #[test]
691 fn invalid_utf8_byte_renders_as_angle_hex() {
692 let rows = render_line(&[0xFF], &opts(8, true), None);
693 assert_eq!(rows[0][0], ch('<'));
694 assert_eq!(rows[0][1], ch('F'));
695 assert_eq!(rows[0][2], ch('F'));
696 assert_eq!(rows[0][3], ch('>'));
697 }
698
699 #[test]
700 fn partial_multibyte_each_byte_renders_separately() {
701 let rows = render_line(&[0xC3], &opts(8, true), None);
703 assert_eq!(rows[0][0], ch('<'));
704 assert_eq!(rows[0][1], ch('C'));
705 assert_eq!(rows[0][2], ch('3'));
706 assert_eq!(rows[0][3], ch('>'));
707 }
708
709 #[test]
710 fn single_byte_utf8_e_acute() {
711 let rows = render_line("é".as_bytes(), &opts(5, true), None);
712 assert_eq!(
713 rows[0][0],
714 Cell::Char { ch: 'é', width: 1, style: crate::ansi::Style::default(), hyperlink: None }
715 );
716 }
717
718 #[test]
719 fn cjk_char_takes_two_columns() {
720 let rows = render_line("日".as_bytes(), &opts(5, true), None);
722 assert_eq!(
723 rows[0][0],
724 Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
725 );
726 assert_eq!(rows[0][1], Cell::Continuation);
727 assert_eq!(rows[0][2], Cell::Empty);
728 }
729
730 #[test]
731 fn emoji_takes_two_columns() {
732 let rows = render_line("🦀".as_bytes(), &opts(5, true), None);
733 assert!(matches!(rows[0][0], Cell::Char { width: 2, .. }));
735 assert_eq!(rows[0][1], Cell::Continuation);
736 }
737
738 #[test]
739 fn combining_mark_folds_into_prior_cell() {
740 let rows = render_line("e\u{0301}".as_bytes(), &opts(5, true), None);
742 assert!(matches!(rows[0][0], Cell::Char { width: 1, .. }));
744 assert_eq!(rows[0][1], Cell::Empty);
745 }
746
747 #[test]
748 fn wrap_long_line_into_multiple_rows() {
749 let rows = render_line(b"abcdefghij", &opts(4, true), None);
750 assert_eq!(rows.len(), 3);
751 assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
752 assert_eq!(rows[1], vec![ch('e'), ch('f'), ch('g'), ch('h')]);
753 assert_eq!(rows[2], vec![ch('i'), ch('j'), Cell::Empty, Cell::Empty]);
754 }
755
756 #[test]
757 fn chop_long_line_truncates() {
758 let rows = render_line(b"abcdefghij", &opts(4, false), None);
759 assert_eq!(rows.len(), 1);
760 assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
761 }
762
763 #[test]
764 fn wide_char_at_boundary_pushed_to_next_row() {
765 let rows = render_line("ab日".as_bytes(), &opts(3, true), None);
768 assert_eq!(rows.len(), 2);
769 assert_eq!(rows[0], vec![ch('a'), ch('b'), Cell::Empty]);
770 assert_eq!(
771 rows[1][0],
772 Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
773 );
774 assert_eq!(rows[1][1], Cell::Continuation);
775 assert_eq!(rows[1][2], Cell::Empty);
776 }
777
778 #[test]
779 fn count_rows_matches_render_line_for_short() {
780 let o = opts(80, true);
781 let bytes = b"hello world";
782 assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
783 }
784
785 #[test]
786 fn count_rows_matches_render_line_for_long_wrap() {
787 let o = opts(4, true);
788 let bytes = b"abcdefghij";
789 assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
790 }
791
792 #[test]
793 fn count_rows_chop_is_one() {
794 let o = opts(4, false);
795 let bytes = b"abcdefghij";
796 assert_eq!(count_rows(bytes, &o, None), 1);
797 }
798
799 #[test]
800 fn count_rows_handles_wide_char() {
801 let o = opts(3, true);
802 let bytes = "ab日".as_bytes();
803 assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
804 }
805
806 fn interpret_opts() -> RenderOpts {
809 RenderOpts { mode: AnsiMode::Interpret, ..Default::default() }
810 }
811
812 #[test]
813 fn interpret_red_text() {
814 let mut state = RenderState::default();
815 let rows = render_line(b"\x1b[31mhi", &interpret_opts(), Some(&mut state));
816 let cells: Vec<&Cell> =
817 rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
818 assert_eq!(cells.len(), 2);
819 for c in cells {
820 if let Cell::Char { style, .. } = c {
821 assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
822 }
823 }
824 }
825
826 #[test]
827 fn interpret_truecolor() {
828 let mut state = RenderState::default();
829 let rows =
830 render_line(b"\x1b[38;2;255;0;0mfoo", &interpret_opts(), Some(&mut state));
831 let cells: Vec<&Cell> =
832 rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
833 for c in cells {
834 if let Cell::Char { style, .. } = c {
835 assert_eq!(style.fg, Some(crate::ansi::Color::Rgb(255, 0, 0)));
836 }
837 }
838 }
839
840 #[test]
841 fn interpret_wide_char_carries_color() {
842 let mut state = RenderState::default();
843 let rows =
844 render_line("\x1b[31m日".as_bytes(), &interpret_opts(), Some(&mut state));
845 let jp_cell = rows.iter().flatten().find_map(|c| match c {
846 Cell::Char { ch: '日', style, width, .. } => Some((style, *width)),
847 _ => None,
848 });
849 let (style, width) = jp_cell.expect("expected 日 cell");
850 assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
851 assert_eq!(width, 2);
852 }
853
854 #[test]
855 fn interpret_state_persists_across_calls() {
856 let mut state = RenderState::default();
857 let _ = render_line(b"\x1b[31mline1", &interpret_opts(), Some(&mut state));
858 let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
859 let l_cell = rows.iter().flatten().find_map(|c| match c {
860 Cell::Char { ch: 'l', style, .. } => Some(style),
861 _ => None,
862 });
863 assert_eq!(
864 l_cell.expect("expected l cell").fg,
865 Some(crate::ansi::Color::Ansi(1))
866 );
867 }
868
869 #[test]
870 fn interpret_reset_clears_state() {
871 let mut state = RenderState::default();
872 let _ =
873 render_line(b"\x1b[31mline1\x1b[0m", &interpret_opts(), Some(&mut state));
874 let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
875 let l_cell = rows.iter().flatten().find_map(|c| match c {
876 Cell::Char { ch: 'l', style, .. } => Some(style),
877 _ => None,
878 });
879 assert_eq!(l_cell.expect("expected l cell"), &crate::ansi::Style::default());
880 }
881
882 #[test]
883 fn interpret_non_sgr_csi_is_zero_width() {
884 let mut state = RenderState::default();
885 let rows = render_line(b"\x1b[2Jdata", &interpret_opts(), Some(&mut state));
886 let chars: String = rows
887 .iter()
888 .flatten()
889 .filter_map(|c| match c {
890 Cell::Char { ch, .. } => Some(*ch),
891 _ => None,
892 })
893 .collect();
894 assert_eq!(chars, "data");
895 }
896
897 #[test]
898 fn strict_mode_esc_still_renders_as_caret_lbracket() {
899 let rows = render_line(b"\x1b[31mhi", &RenderOpts::default(), None);
901 let chars: String = rows
902 .iter()
903 .flatten()
904 .filter_map(|c| match c {
905 Cell::Char { ch, .. } => Some(*ch),
906 _ => None,
907 })
908 .collect();
909 assert!(chars.starts_with("^["), "got: {chars:?}");
910 }
911
912 #[test]
913 fn osc8_hyperlink_attached_to_cells() {
914 let mut state = RenderState::default();
915 let rows = render_line(
916 b"\x1b]8;;https://example.com\x07click\x1b]8;;\x07",
917 &interpret_opts(),
918 Some(&mut state),
919 );
920 let click_cell = rows.iter().flatten().find_map(|c| match c {
921 Cell::Char { ch: 'c', hyperlink, .. } => Some(hyperlink.clone()),
922 _ => None,
923 });
924 let link = click_cell.expect("expected c cell").expect("expected hyperlink");
925 assert_eq!(link.as_ref(), "https://example.com");
926 }
927}