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