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}
50
51impl Default for RenderOpts {
52 fn default() -> Self {
53 Self { tab_width: 8, wrap: true, cols: 80, mode: AnsiMode::Strict }
54 }
55}
56
57fn decode_cluster(bytes: &[u8], i: usize) -> Option<(&str, usize)> {
61 let max = (i + 4).min(bytes.len());
69 let mut end = i;
70 for try_end in (i + 1)..=max {
71 if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
72 end = try_end;
73 break;
74 }
75 }
76 if end == i {
77 return None;
78 }
79
80 let mut probe_end = end;
85 loop {
86 let probe_max = (probe_end + 4).min(bytes.len());
88 let mut next_end = probe_end;
89 for try_end in (probe_end + 1)..=probe_max {
90 if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
91 next_end = try_end;
92 break;
93 }
94 }
95 if next_end == probe_end {
96 break;
97 }
98 let candidate = std::str::from_utf8(&bytes[i..next_end]).unwrap();
99 let cluster_count = candidate.graphemes(true).count();
100 if cluster_count > 1 {
101 break;
103 }
104 probe_end = next_end;
105 }
106
107 Some((std::str::from_utf8(&bytes[i..probe_end]).unwrap(), probe_end - i))
108}
109
110fn prefilter(
117 bytes: &[u8],
118 mode: AnsiMode,
119 state: Option<&mut RenderState>,
120) -> Vec<(u8, crate::ansi::Style, Option<Arc<str>>)> {
121 match mode {
122 AnsiMode::Strict | AnsiMode::Raw => {
123 bytes
126 .iter()
127 .map(|&b| (b, crate::ansi::Style::default(), None))
128 .collect()
129 }
130 AnsiMode::Interpret => {
131 use crate::ansi::ParseStep;
132 let mut tmp;
134 let st: &mut RenderState = match state {
135 Some(s) => s,
136 None => {
137 tmp = RenderState::default();
138 &mut tmp
139 }
140 };
141 let mut out = Vec::with_capacity(bytes.len());
142 for &b in bytes {
143 let step =
144 crate::ansi::step(&mut st.parse, &mut st.style, &mut st.hyperlink, b);
145 if let ParseStep::Printable(pb) = step {
146 let hl = st.hyperlink.as_deref().map(Arc::from);
147 out.push((pb, st.style, hl));
148 }
149 }
150 out
151 }
152 }
153}
154
155pub fn render_line(
156 bytes: &[u8],
157 opts: &RenderOpts,
158 state: Option<&mut RenderState>,
159) -> Vec<Vec<Cell>> {
160 let cols = opts.cols as usize;
161 let mut rows: Vec<Vec<Cell>> = Vec::new();
162 let mut current: Vec<Cell> = Vec::with_capacity(cols);
163
164 let filtered = prefilter(bytes, opts.mode, state);
166
167 fn push(current: &mut Vec<Cell>, rows: &mut Vec<Vec<Cell>>, cell: Cell, opts: &RenderOpts) {
168 if current.len() >= opts.cols as usize {
169 if opts.wrap {
170 let mut full = std::mem::replace(current, Vec::with_capacity(opts.cols as usize));
171 while full.len() < opts.cols as usize { full.push(Cell::Empty); }
172 rows.push(full);
173 } else {
174 return;
175 }
176 }
177 current.push(cell);
178 }
179
180 fn push_str(
181 current: &mut Vec<Cell>,
182 rows: &mut Vec<Vec<Cell>>,
183 s: &str,
184 style: crate::ansi::Style,
185 hyperlink: Option<Arc<str>>,
186 opts: &RenderOpts,
187 ) {
188 for c in s.chars() {
189 push(
190 current,
191 rows,
192 Cell::Char { ch: c, width: 1, style, hyperlink: hyperlink.clone() },
193 opts,
194 );
195 }
196 }
197
198 fn push_wide(
199 current: &mut Vec<Cell>,
200 rows: &mut Vec<Vec<Cell>>,
201 ch: char,
202 width: u8,
203 style: crate::ansi::Style,
204 hyperlink: Option<Arc<str>>,
205 opts: &RenderOpts,
206 ) {
207 let cols = opts.cols as usize;
208 if current.len() + width as usize > cols {
210 if opts.wrap {
211 let mut full = std::mem::replace(current, Vec::with_capacity(cols));
212 while full.len() < cols { full.push(Cell::Empty); }
213 rows.push(full);
214 } else {
215 return; }
217 }
218 current.push(Cell::Char { ch, width, style, hyperlink });
219 for _ in 1..width {
220 current.push(Cell::Continuation);
221 }
222 }
223
224 let mut i = 0;
226 while i < filtered.len() {
227 let (b, style, hyperlink) = filtered[i].clone();
228 if b == b'\t' {
229 let stop = opts.tab_width.max(1) as usize;
230 let cur_col = current.len();
231 let next_stop = ((cur_col / stop) + 1) * stop;
232 for _ in cur_col..next_stop {
233 push(
234 &mut current,
235 &mut rows,
236 Cell::Char { ch: ' ', width: 1, style, hyperlink: hyperlink.clone() },
237 opts,
238 );
239 }
240 i += 1;
241 } else if b == b'\n' {
242 i += 1;
243 } else if b < 0x20 || b == 0x7F {
244 let printable = if b == 0x7F { '?' } else { (b ^ 0x40) as char };
245 push(
246 &mut current,
247 &mut rows,
248 Cell::Char { ch: '^', width: 1, style, hyperlink: hyperlink.clone() },
249 opts,
250 );
251 push(
252 &mut current,
253 &mut rows,
254 Cell::Char { ch: printable, width: 1, style, hyperlink },
255 opts,
256 );
257 i += 1;
258 } else {
259 let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
262 match decode_cluster(&raw_bytes, 0) {
263 Some((cluster, consumed)) => {
264 let w = UnicodeWidthStr::width(cluster) as u8;
265 let base_char = cluster.chars().next().unwrap_or('\u{FFFD}');
266 if w == 0 {
267 push(
269 &mut current,
270 &mut rows,
271 Cell::Char {
272 ch: '\u{FFFD}',
273 width: 1,
274 style,
275 hyperlink,
276 },
277 opts,
278 );
279 } else {
280 push_wide(&mut current, &mut rows, base_char, w, style, hyperlink, opts);
281 }
282 i += consumed;
283 }
284 None => {
285 let s = format!("<{:02X}>", b);
287 push_str(&mut current, &mut rows, &s, style, hyperlink, opts);
288 i += 1;
289 }
290 }
291 }
292 }
293
294 while current.len() < cols {
295 current.push(Cell::Empty);
296 }
297 rows.push(current);
298 rows
299}
300
301pub fn count_rows(
302 bytes: &[u8],
303 opts: &RenderOpts,
304 state: Option<&mut RenderState>,
305) -> usize {
306 if !opts.wrap {
307 return 1;
308 }
309 let cols = opts.cols.max(1) as usize;
310 let mut col = 0usize;
311 let mut rows = 1usize;
312
313 let bump = |w: usize, col: &mut usize, rows: &mut usize| {
314 if *col + w > cols {
315 *rows += 1;
316 *col = 0;
317 }
318 *col += w;
319 };
320
321 let filtered = prefilter(bytes, opts.mode, state);
323
324 let mut i = 0;
325 while i < filtered.len() {
326 let (b, _, _) = filtered[i];
327 if b == b'\t' {
328 let stop = opts.tab_width.max(1) as usize;
329 let next_stop = ((col / stop) + 1) * stop;
330 let advance = next_stop - col;
331 for _ in 0..advance {
333 bump(1, &mut col, &mut rows);
334 }
335 i += 1;
336 } else if b == b'\n' {
337 i += 1;
338 } else if b < 0x20 || b == 0x7F {
339 bump(1, &mut col, &mut rows); bump(1, &mut col, &mut rows); i += 1;
342 } else {
343 let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
344 match decode_cluster(&raw_bytes, 0) {
345 Some((cluster, consumed)) => {
346 let w = UnicodeWidthStr::width(cluster);
347 let w = if w == 0 { 1 } else { w };
348 bump(w, &mut col, &mut rows);
349 i += consumed;
350 }
351 None => {
352 for _ in 0..4 { bump(1, &mut col, &mut rows); }
354 i += 1;
355 }
356 }
357 }
358 }
359 rows
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 fn opts(cols: u16, wrap: bool) -> RenderOpts {
367 RenderOpts { tab_width: 8, wrap, cols, mode: AnsiMode::Strict }
368 }
369
370 fn ch(c: char) -> Cell {
371 Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None }
372 }
373
374 #[test]
375 fn ascii_short_line_pads_to_cols() {
376 let rows = render_line(b"hi", &opts(5, true), None);
377 assert_eq!(rows.len(), 1);
378 assert_eq!(rows[0], vec![ch('h'), ch('i'), Cell::Empty, Cell::Empty, Cell::Empty]);
379 }
380
381 #[test]
382 fn ascii_exact_width() {
383 let rows = render_line(b"hello", &opts(5, true), None);
384 assert_eq!(rows.len(), 1);
385 assert_eq!(rows[0], vec![ch('h'), ch('e'), ch('l'), ch('l'), ch('o')]);
386 }
387
388 #[test]
389 fn empty_input_yields_one_empty_row() {
390 let rows = render_line(b"", &opts(3, true), None);
391 assert_eq!(rows, vec![vec![Cell::Empty, Cell::Empty, Cell::Empty]]);
392 }
393
394 #[test]
395 fn tab_at_col_zero_expands_to_eight() {
396 let rows = render_line(b"\tx", &opts(20, true), None);
397 for (i, cell) in rows[0].iter().take(8).enumerate() {
399 assert_eq!(*cell, ch(' '), "col {i} should be space");
400 }
401 assert_eq!(rows[0][8], ch('x'));
402 }
403
404 #[test]
405 fn tab_at_col_three_advances_to_next_stop() {
406 let rows = render_line(b"abc\tx", &opts(20, true), None);
408 assert_eq!(rows[0][0], ch('a'));
409 assert_eq!(rows[0][2], ch('c'));
410 for cell in rows[0].iter().skip(3).take(5) {
411 assert_eq!(*cell, ch(' '));
412 }
413 assert_eq!(rows[0][8], ch('x'));
414 }
415
416 #[test]
417 fn tab_at_col_eight_advances_to_sixteen() {
418 let mut input = vec![b'a'; 8];
419 input.push(b'\t');
420 input.push(b'x');
421 let rows = render_line(&input, &opts(20, true), None);
422 for cell in rows[0].iter().skip(8).take(8) {
423 assert_eq!(*cell, ch(' '));
424 }
425 assert_eq!(rows[0][16], ch('x'));
426 }
427
428 #[test]
429 fn null_renders_as_caret_at() {
430 let rows = render_line(b"\0", &opts(5, true), None);
431 assert_eq!(rows[0][0], ch('^'));
432 assert_eq!(rows[0][1], ch('@'));
433 }
434
435 #[test]
436 fn esc_renders_as_caret_lbracket() {
437 let rows = render_line(b"\x1b", &opts(5, true), None);
438 assert_eq!(rows[0][0], ch('^'));
439 assert_eq!(rows[0][1], ch('['));
440 }
441
442 #[test]
443 fn del_renders_as_caret_question() {
444 let rows = render_line(b"\x7f", &opts(5, true), None);
445 assert_eq!(rows[0][0], ch('^'));
446 assert_eq!(rows[0][1], ch('?'));
447 }
448
449 #[test]
450 fn invalid_utf8_byte_renders_as_angle_hex() {
451 let rows = render_line(&[0xFF], &opts(8, true), None);
452 assert_eq!(rows[0][0], ch('<'));
453 assert_eq!(rows[0][1], ch('F'));
454 assert_eq!(rows[0][2], ch('F'));
455 assert_eq!(rows[0][3], ch('>'));
456 }
457
458 #[test]
459 fn partial_multibyte_each_byte_renders_separately() {
460 let rows = render_line(&[0xC3], &opts(8, true), None);
462 assert_eq!(rows[0][0], ch('<'));
463 assert_eq!(rows[0][1], ch('C'));
464 assert_eq!(rows[0][2], ch('3'));
465 assert_eq!(rows[0][3], ch('>'));
466 }
467
468 #[test]
469 fn single_byte_utf8_e_acute() {
470 let rows = render_line("é".as_bytes(), &opts(5, true), None);
471 assert_eq!(
472 rows[0][0],
473 Cell::Char { ch: 'é', width: 1, style: crate::ansi::Style::default(), hyperlink: None }
474 );
475 }
476
477 #[test]
478 fn cjk_char_takes_two_columns() {
479 let rows = render_line("日".as_bytes(), &opts(5, true), None);
481 assert_eq!(
482 rows[0][0],
483 Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
484 );
485 assert_eq!(rows[0][1], Cell::Continuation);
486 assert_eq!(rows[0][2], Cell::Empty);
487 }
488
489 #[test]
490 fn emoji_takes_two_columns() {
491 let rows = render_line("🦀".as_bytes(), &opts(5, true), None);
492 assert!(matches!(rows[0][0], Cell::Char { width: 2, .. }));
494 assert_eq!(rows[0][1], Cell::Continuation);
495 }
496
497 #[test]
498 fn combining_mark_folds_into_prior_cell() {
499 let rows = render_line("e\u{0301}".as_bytes(), &opts(5, true), None);
501 assert!(matches!(rows[0][0], Cell::Char { width: 1, .. }));
503 assert_eq!(rows[0][1], Cell::Empty);
504 }
505
506 #[test]
507 fn wrap_long_line_into_multiple_rows() {
508 let rows = render_line(b"abcdefghij", &opts(4, true), None);
509 assert_eq!(rows.len(), 3);
510 assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
511 assert_eq!(rows[1], vec![ch('e'), ch('f'), ch('g'), ch('h')]);
512 assert_eq!(rows[2], vec![ch('i'), ch('j'), Cell::Empty, Cell::Empty]);
513 }
514
515 #[test]
516 fn chop_long_line_truncates() {
517 let rows = render_line(b"abcdefghij", &opts(4, false), None);
518 assert_eq!(rows.len(), 1);
519 assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
520 }
521
522 #[test]
523 fn wide_char_at_boundary_pushed_to_next_row() {
524 let rows = render_line("ab日".as_bytes(), &opts(3, true), None);
527 assert_eq!(rows.len(), 2);
528 assert_eq!(rows[0], vec![ch('a'), ch('b'), Cell::Empty]);
529 assert_eq!(
530 rows[1][0],
531 Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
532 );
533 assert_eq!(rows[1][1], Cell::Continuation);
534 assert_eq!(rows[1][2], Cell::Empty);
535 }
536
537 #[test]
538 fn count_rows_matches_render_line_for_short() {
539 let o = opts(80, true);
540 let bytes = b"hello world";
541 assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
542 }
543
544 #[test]
545 fn count_rows_matches_render_line_for_long_wrap() {
546 let o = opts(4, true);
547 let bytes = b"abcdefghij";
548 assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
549 }
550
551 #[test]
552 fn count_rows_chop_is_one() {
553 let o = opts(4, false);
554 let bytes = b"abcdefghij";
555 assert_eq!(count_rows(bytes, &o, None), 1);
556 }
557
558 #[test]
559 fn count_rows_handles_wide_char() {
560 let o = opts(3, true);
561 let bytes = "ab日".as_bytes();
562 assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
563 }
564
565 fn interpret_opts() -> RenderOpts {
568 RenderOpts { mode: AnsiMode::Interpret, ..Default::default() }
569 }
570
571 #[test]
572 fn interpret_red_text() {
573 let mut state = RenderState::default();
574 let rows = render_line(b"\x1b[31mhi", &interpret_opts(), Some(&mut state));
575 let cells: Vec<&Cell> =
576 rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
577 assert_eq!(cells.len(), 2);
578 for c in cells {
579 if let Cell::Char { style, .. } = c {
580 assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
581 }
582 }
583 }
584
585 #[test]
586 fn interpret_truecolor() {
587 let mut state = RenderState::default();
588 let rows =
589 render_line(b"\x1b[38;2;255;0;0mfoo", &interpret_opts(), Some(&mut state));
590 let cells: Vec<&Cell> =
591 rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
592 for c in cells {
593 if let Cell::Char { style, .. } = c {
594 assert_eq!(style.fg, Some(crate::ansi::Color::Rgb(255, 0, 0)));
595 }
596 }
597 }
598
599 #[test]
600 fn interpret_wide_char_carries_color() {
601 let mut state = RenderState::default();
602 let rows =
603 render_line("\x1b[31m日".as_bytes(), &interpret_opts(), Some(&mut state));
604 let jp_cell = rows.iter().flatten().find_map(|c| match c {
605 Cell::Char { ch: '日', style, width, .. } => Some((style, *width)),
606 _ => None,
607 });
608 let (style, width) = jp_cell.expect("expected 日 cell");
609 assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
610 assert_eq!(width, 2);
611 }
612
613 #[test]
614 fn interpret_state_persists_across_calls() {
615 let mut state = RenderState::default();
616 let _ = render_line(b"\x1b[31mline1", &interpret_opts(), Some(&mut state));
617 let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
618 let l_cell = rows.iter().flatten().find_map(|c| match c {
619 Cell::Char { ch: 'l', style, .. } => Some(style),
620 _ => None,
621 });
622 assert_eq!(
623 l_cell.expect("expected l cell").fg,
624 Some(crate::ansi::Color::Ansi(1))
625 );
626 }
627
628 #[test]
629 fn interpret_reset_clears_state() {
630 let mut state = RenderState::default();
631 let _ =
632 render_line(b"\x1b[31mline1\x1b[0m", &interpret_opts(), Some(&mut state));
633 let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
634 let l_cell = rows.iter().flatten().find_map(|c| match c {
635 Cell::Char { ch: 'l', style, .. } => Some(style),
636 _ => None,
637 });
638 assert_eq!(l_cell.expect("expected l cell"), &crate::ansi::Style::default());
639 }
640
641 #[test]
642 fn interpret_non_sgr_csi_is_zero_width() {
643 let mut state = RenderState::default();
644 let rows = render_line(b"\x1b[2Jdata", &interpret_opts(), Some(&mut state));
645 let chars: String = rows
646 .iter()
647 .flatten()
648 .filter_map(|c| match c {
649 Cell::Char { ch, .. } => Some(*ch),
650 _ => None,
651 })
652 .collect();
653 assert_eq!(chars, "data");
654 }
655
656 #[test]
657 fn strict_mode_esc_still_renders_as_caret_lbracket() {
658 let rows = render_line(b"\x1b[31mhi", &RenderOpts::default(), None);
660 let chars: String = rows
661 .iter()
662 .flatten()
663 .filter_map(|c| match c {
664 Cell::Char { ch, .. } => Some(*ch),
665 _ => None,
666 })
667 .collect();
668 assert!(chars.starts_with("^["), "got: {chars:?}");
669 }
670
671 #[test]
672 fn osc8_hyperlink_attached_to_cells() {
673 let mut state = RenderState::default();
674 let rows = render_line(
675 b"\x1b]8;;https://example.com\x07click\x1b]8;;\x07",
676 &interpret_opts(),
677 Some(&mut state),
678 );
679 let click_cell = rows.iter().flatten().find_map(|c| match c {
680 Cell::Char { ch: 'c', hyperlink, .. } => Some(hyperlink.clone()),
681 _ => None,
682 });
683 let link = click_cell.expect("expected c cell").expect("expected hyperlink");
684 assert_eq!(link.as_ref(), "https://example.com");
685 }
686}