use std::sync::Arc;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnsiMode {
#[default]
Strict,
Interpret,
Raw,
}
#[derive(Debug, Default, Clone)]
pub struct RenderState {
pub style: crate::ansi::Style,
pub hyperlink: Option<String>,
pub parse: crate::ansi::ParseState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cell {
Char {
ch: char,
width: u8,
style: crate::ansi::Style,
hyperlink: Option<Arc<str>>,
},
Continuation,
Empty,
}
#[derive(Debug, Clone)]
pub struct RenderOpts {
pub tab_width: u8,
pub wrap: bool,
pub cols: u16,
pub mode: AnsiMode,
pub rscroll_char: Option<char>,
pub word_wrap: bool,
}
impl Default for RenderOpts {
fn default() -> Self {
Self {
tab_width: 8, wrap: true, cols: 80,
mode: AnsiMode::Strict, rscroll_char: None, word_wrap: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TrueColor {
Always,
Never,
#[default]
Auto,
}
impl TrueColor {
pub fn resolve(self) -> bool {
match self {
TrueColor::Always => true,
TrueColor::Never => false,
TrueColor::Auto => matches!(
std::env::var("COLORTERM").ok().as_deref(),
Some("truecolor") | Some("24bit"),
),
}
}
}
pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
if r == g && g == b {
if r < 8 { return 16; }
if r > 248 { return 231; }
return 232 + ((r as u16 - 8) * 24 / 240) as u8;
}
let q = |c: u8| -> u8 {
if c < 48 { 0 }
else if c < 115 { 1 }
else { ((c as u16 - 35) / 40) as u8 }
};
16 + 36 * q(r) + 6 * q(g) + q(b)
}
fn decode_cluster(bytes: &[u8], i: usize) -> Option<(&str, usize)> {
let max = (i + 4).min(bytes.len());
let mut end = i;
for try_end in (i + 1)..=max {
if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
end = try_end;
break;
}
}
if end == i {
return None;
}
let mut probe_end = end;
loop {
let probe_max = (probe_end + 4).min(bytes.len());
let mut next_end = probe_end;
for try_end in (probe_end + 1)..=probe_max {
if std::str::from_utf8(&bytes[i..try_end]).is_ok() {
next_end = try_end;
break;
}
}
if next_end == probe_end {
break;
}
let candidate = std::str::from_utf8(&bytes[i..next_end]).unwrap();
let cluster_count = candidate.graphemes(true).count();
if cluster_count > 1 {
break;
}
probe_end = next_end;
}
Some((std::str::from_utf8(&bytes[i..probe_end]).unwrap(), probe_end - i))
}
fn prefilter(
bytes: &[u8],
mode: AnsiMode,
state: Option<&mut RenderState>,
) -> Vec<(u8, crate::ansi::Style, Option<Arc<str>>)> {
match mode {
AnsiMode::Strict | AnsiMode::Raw => {
bytes
.iter()
.map(|&b| (b, crate::ansi::Style::default(), None))
.collect()
}
AnsiMode::Interpret => {
use crate::ansi::ParseStep;
let mut tmp;
let st: &mut RenderState = match state {
Some(s) => s,
None => {
tmp = RenderState::default();
&mut tmp
}
};
let mut out = Vec::with_capacity(bytes.len());
for &b in bytes {
let step =
crate::ansi::step(&mut st.parse, &mut st.style, &mut st.hyperlink, b);
if let ParseStep::Printable(pb) = step {
let hl = st.hyperlink.as_deref().map(Arc::from);
out.push((pb, st.style, hl));
}
}
out
}
}
}
pub fn render_line(
bytes: &[u8],
opts: &RenderOpts,
state: Option<&mut RenderState>,
) -> Vec<Vec<Cell>> {
let cols = opts.cols as usize;
let mut rows: Vec<Vec<Cell>> = Vec::new();
let mut current: Vec<Cell> = Vec::with_capacity(cols);
let filtered = prefilter(bytes, opts.mode, state);
fn push(current: &mut Vec<Cell>, rows: &mut Vec<Vec<Cell>>, cell: Cell, opts: &RenderOpts) -> bool {
if current.len() >= opts.cols as usize {
if opts.wrap {
let mut full = std::mem::replace(current, Vec::with_capacity(opts.cols as usize));
if opts.word_wrap {
if let Some(ws_idx) = (0..full.len()).rev().find(|&i| matches!(
full[i],
Cell::Char { ch, .. } if ch == ' ' || ch == '\t'
)) {
let carry: Vec<Cell> = full.drain((ws_idx + 1)..).collect();
*current = carry;
}
}
while full.len() < opts.cols as usize { full.push(Cell::Empty); }
rows.push(full);
} else {
return true;
}
}
current.push(cell);
false
}
fn push_str(
current: &mut Vec<Cell>,
rows: &mut Vec<Vec<Cell>>,
s: &str,
style: crate::ansi::Style,
hyperlink: Option<Arc<str>>,
opts: &RenderOpts,
) -> bool {
let mut overflowed = false;
for c in s.chars() {
overflowed |= push(
current,
rows,
Cell::Char { ch: c, width: 1, style, hyperlink: hyperlink.clone() },
opts,
);
}
overflowed
}
fn push_wide(
current: &mut Vec<Cell>,
rows: &mut Vec<Vec<Cell>>,
ch: char,
width: u8,
style: crate::ansi::Style,
hyperlink: Option<Arc<str>>,
opts: &RenderOpts,
) -> bool {
let cols = opts.cols as usize;
if current.len() + width as usize > cols {
if opts.wrap {
let mut full = std::mem::replace(current, Vec::with_capacity(cols));
if opts.word_wrap {
if let Some(ws_idx) = (0..full.len()).rev().find(|&i| matches!(
full[i],
Cell::Char { ch, .. } if ch == ' ' || ch == '\t'
)) {
let carry: Vec<Cell> = full.drain((ws_idx + 1)..).collect();
*current = carry;
}
}
while full.len() < cols { full.push(Cell::Empty); }
rows.push(full);
} else {
return true; }
}
current.push(Cell::Char { ch, width, style, hyperlink });
for _ in 1..width {
current.push(Cell::Continuation);
}
false
}
let mut overflowed = false;
let mut i = 0;
while i < filtered.len() {
let (b, style, hyperlink) = filtered[i].clone();
if b == b'\t' {
let stop = opts.tab_width.max(1) as usize;
let cur_col = current.len();
let next_stop = ((cur_col / stop) + 1) * stop;
for _ in cur_col..next_stop {
overflowed |= push(
&mut current,
&mut rows,
Cell::Char { ch: ' ', width: 1, style, hyperlink: hyperlink.clone() },
opts,
);
}
i += 1;
} else if b == b'\n' {
i += 1;
} else if b < 0x20 || b == 0x7F {
let printable = if b == 0x7F { '?' } else { (b ^ 0x40) as char };
overflowed |= push(
&mut current,
&mut rows,
Cell::Char { ch: '^', width: 1, style, hyperlink: hyperlink.clone() },
opts,
);
overflowed |= push(
&mut current,
&mut rows,
Cell::Char { ch: printable, width: 1, style, hyperlink },
opts,
);
i += 1;
} else {
let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
match decode_cluster(&raw_bytes, 0) {
Some((cluster, consumed)) => {
let w = UnicodeWidthStr::width(cluster) as u8;
let base_char = cluster.chars().next().unwrap_or('\u{FFFD}');
if w == 0 {
overflowed |= push(
&mut current,
&mut rows,
Cell::Char {
ch: '\u{FFFD}',
width: 1,
style,
hyperlink,
},
opts,
);
} else {
overflowed |= push_wide(&mut current, &mut rows, base_char, w, style, hyperlink, opts);
}
i += consumed;
}
None => {
let s = format!("<{:02X}>", b);
overflowed |= push_str(&mut current, &mut rows, &s, style, hyperlink, opts);
i += 1;
}
}
}
}
while current.len() < cols {
current.push(Cell::Empty);
}
if !opts.wrap && overflowed && cols > 0 {
if let Some(marker) = opts.rscroll_char {
current[cols - 1] = Cell::Char {
ch: marker,
width: 1,
style: crate::ansi::Style { dim: true, ..Default::default() },
hyperlink: None,
};
}
}
rows.push(current);
rows
}
pub fn count_rows(
bytes: &[u8],
opts: &RenderOpts,
state: Option<&mut RenderState>,
) -> usize {
if !opts.wrap {
return 1;
}
let cols = opts.cols.max(1) as usize;
let mut col = 0usize;
let mut rows = 1usize;
let bump = |w: usize, col: &mut usize, rows: &mut usize| {
if *col + w > cols {
*rows += 1;
*col = 0;
}
*col += w;
};
let filtered = prefilter(bytes, opts.mode, state);
let mut i = 0;
while i < filtered.len() {
let (b, _, _) = filtered[i];
if b == b'\t' {
let stop = opts.tab_width.max(1) as usize;
let next_stop = ((col / stop) + 1) * stop;
let advance = next_stop - col;
for _ in 0..advance {
bump(1, &mut col, &mut rows);
}
i += 1;
} else if b == b'\n' {
i += 1;
} else if b < 0x20 || b == 0x7F {
bump(1, &mut col, &mut rows); bump(1, &mut col, &mut rows); i += 1;
} else {
let raw_bytes: Vec<u8> = filtered[i..].iter().map(|(b, _, _)| *b).collect();
match decode_cluster(&raw_bytes, 0) {
Some((cluster, consumed)) => {
let w = UnicodeWidthStr::width(cluster);
let w = if w == 0 { 1 } else { w };
bump(w, &mut col, &mut rows);
i += consumed;
}
None => {
for _ in 0..4 { bump(1, &mut col, &mut rows); }
i += 1;
}
}
}
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb_to_256_pure_corners_map_to_palette_extremes() {
assert_eq!(rgb_to_256(0, 0, 0), 16);
assert_eq!(rgb_to_256(255, 255, 255), 231);
}
#[test]
fn rgb_to_256_mid_gray_lands_in_grayscale_ramp() {
let n = rgb_to_256(128, 128, 128);
assert!((232..=255).contains(&n), "expected grayscale slot 232..=255, got {n}");
}
#[test]
fn rgb_to_256_pure_rgb_lands_in_cube_extremes() {
assert_eq!(rgb_to_256(255, 0, 0), 196);
assert_eq!(rgb_to_256(0, 255, 0), 46);
assert_eq!(rgb_to_256(0, 0, 255), 21);
}
#[test]
fn rgb_to_256_low_channel_quantizes_to_zero() {
assert_eq!(rgb_to_256(40, 200, 0), 40);
}
#[test]
fn rgb_to_256_near_black_gray_is_palette_black() {
assert_eq!(rgb_to_256(5, 5, 5), 16);
}
#[test]
fn rgb_to_256_near_white_gray_is_palette_white() {
assert_eq!(rgb_to_256(250, 250, 250), 231);
}
#[test]
fn truecolor_always_resolves_true_regardless_of_env() {
assert!(TrueColor::Always.resolve());
}
#[test]
fn truecolor_never_resolves_false_regardless_of_env() {
assert!(!TrueColor::Never.resolve());
}
#[test]
fn rscroll_marker_appears_on_chopped_row() {
let mut o = opts(5, false); o.rscroll_char = Some('>');
let rows = render_line(b"abcdefgh", &o, None);
assert_eq!(rows.len(), 1);
match &rows[0][4] {
Cell::Char { ch, .. } => assert_eq!(*ch, '>'),
other => panic!("expected `>` marker, got {other:?}"),
}
}
#[test]
fn rscroll_marker_absent_on_fitting_row() {
let mut o = opts(10, false);
o.rscroll_char = Some('>');
let rows = render_line(b"abc", &o, None);
match &rows[0][2] {
Cell::Char { ch, .. } => assert_eq!(*ch, 'c'),
other => panic!("expected content `c`, got {other:?}"),
}
}
#[test]
fn rscroll_marker_disabled_emits_normal_chop() {
let mut o = opts(5, false);
o.rscroll_char = None;
let rows = render_line(b"abcdefgh", &o, None);
match &rows[0][4] {
Cell::Char { ch, .. } => assert_eq!(*ch, 'e'),
other => panic!("expected last fitting char, got {other:?}"),
}
}
#[test]
fn word_wrap_breaks_on_whitespace() {
let mut o = opts(8, true);
o.word_wrap = true;
let rows = render_line(b"the quick brown fox", &o, None);
let r0: String = rows[0].iter().filter_map(|c| match c {
Cell::Char { ch, .. } => Some(*ch),
_ => None,
}).collect();
assert_eq!(r0.trim_end(), "the");
}
#[test]
fn word_wrap_falls_back_when_no_whitespace_fits() {
let mut o = opts(5, true);
o.word_wrap = true;
let rows = render_line(b"antidisestablishment", &o, None);
let r0: String = rows[0].iter().filter_map(|c| match c {
Cell::Char { ch, .. } => Some(*ch),
_ => None,
}).collect();
assert_eq!(r0.trim_end(), "antid");
}
#[test]
fn word_wrap_off_breaks_mid_word() {
let mut o = opts(8, true);
o.word_wrap = false;
let rows = render_line(b"the quick brown fox", &o, None);
let r0: String = rows[0].iter().filter_map(|c| match c {
Cell::Char { ch, .. } => Some(*ch),
_ => None,
}).collect();
assert_eq!(r0.trim_end(), "the quic");
}
#[test]
fn rscroll_marker_absent_in_wrap_mode() {
let mut o = opts(5, true);
o.rscroll_char = Some('>');
let rows = render_line(b"abcdefgh", &o, None);
assert!(rows.len() > 1);
for row in &rows {
for cell in row {
if let Cell::Char { ch, .. } = cell {
assert_ne!(*ch, '>', "rscroll marker leaked into wrap mode");
}
}
}
}
fn opts(cols: u16, wrap: bool) -> RenderOpts {
RenderOpts { tab_width: 8, wrap, cols, mode: AnsiMode::Strict, rscroll_char: None, word_wrap: false }
}
fn ch(c: char) -> Cell {
Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None }
}
#[test]
fn ascii_short_line_pads_to_cols() {
let rows = render_line(b"hi", &opts(5, true), None);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0], vec![ch('h'), ch('i'), Cell::Empty, Cell::Empty, Cell::Empty]);
}
#[test]
fn ascii_exact_width() {
let rows = render_line(b"hello", &opts(5, true), None);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0], vec![ch('h'), ch('e'), ch('l'), ch('l'), ch('o')]);
}
#[test]
fn empty_input_yields_one_empty_row() {
let rows = render_line(b"", &opts(3, true), None);
assert_eq!(rows, vec![vec![Cell::Empty, Cell::Empty, Cell::Empty]]);
}
#[test]
fn tab_at_col_zero_expands_to_eight() {
let rows = render_line(b"\tx", &opts(20, true), None);
for (i, cell) in rows[0].iter().take(8).enumerate() {
assert_eq!(*cell, ch(' '), "col {i} should be space");
}
assert_eq!(rows[0][8], ch('x'));
}
#[test]
fn tab_at_col_three_advances_to_next_stop() {
let rows = render_line(b"abc\tx", &opts(20, true), None);
assert_eq!(rows[0][0], ch('a'));
assert_eq!(rows[0][2], ch('c'));
for cell in rows[0].iter().skip(3).take(5) {
assert_eq!(*cell, ch(' '));
}
assert_eq!(rows[0][8], ch('x'));
}
#[test]
fn tab_at_col_eight_advances_to_sixteen() {
let mut input = vec![b'a'; 8];
input.push(b'\t');
input.push(b'x');
let rows = render_line(&input, &opts(20, true), None);
for cell in rows[0].iter().skip(8).take(8) {
assert_eq!(*cell, ch(' '));
}
assert_eq!(rows[0][16], ch('x'));
}
#[test]
fn null_renders_as_caret_at() {
let rows = render_line(b"\0", &opts(5, true), None);
assert_eq!(rows[0][0], ch('^'));
assert_eq!(rows[0][1], ch('@'));
}
#[test]
fn esc_renders_as_caret_lbracket() {
let rows = render_line(b"\x1b", &opts(5, true), None);
assert_eq!(rows[0][0], ch('^'));
assert_eq!(rows[0][1], ch('['));
}
#[test]
fn del_renders_as_caret_question() {
let rows = render_line(b"\x7f", &opts(5, true), None);
assert_eq!(rows[0][0], ch('^'));
assert_eq!(rows[0][1], ch('?'));
}
#[test]
fn invalid_utf8_byte_renders_as_angle_hex() {
let rows = render_line(&[0xFF], &opts(8, true), None);
assert_eq!(rows[0][0], ch('<'));
assert_eq!(rows[0][1], ch('F'));
assert_eq!(rows[0][2], ch('F'));
assert_eq!(rows[0][3], ch('>'));
}
#[test]
fn partial_multibyte_each_byte_renders_separately() {
let rows = render_line(&[0xC3], &opts(8, true), None);
assert_eq!(rows[0][0], ch('<'));
assert_eq!(rows[0][1], ch('C'));
assert_eq!(rows[0][2], ch('3'));
assert_eq!(rows[0][3], ch('>'));
}
#[test]
fn single_byte_utf8_e_acute() {
let rows = render_line("é".as_bytes(), &opts(5, true), None);
assert_eq!(
rows[0][0],
Cell::Char { ch: 'é', width: 1, style: crate::ansi::Style::default(), hyperlink: None }
);
}
#[test]
fn cjk_char_takes_two_columns() {
let rows = render_line("日".as_bytes(), &opts(5, true), None);
assert_eq!(
rows[0][0],
Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
);
assert_eq!(rows[0][1], Cell::Continuation);
assert_eq!(rows[0][2], Cell::Empty);
}
#[test]
fn emoji_takes_two_columns() {
let rows = render_line("🦀".as_bytes(), &opts(5, true), None);
assert!(matches!(rows[0][0], Cell::Char { width: 2, .. }));
assert_eq!(rows[0][1], Cell::Continuation);
}
#[test]
fn combining_mark_folds_into_prior_cell() {
let rows = render_line("e\u{0301}".as_bytes(), &opts(5, true), None);
assert!(matches!(rows[0][0], Cell::Char { width: 1, .. }));
assert_eq!(rows[0][1], Cell::Empty);
}
#[test]
fn wrap_long_line_into_multiple_rows() {
let rows = render_line(b"abcdefghij", &opts(4, true), None);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
assert_eq!(rows[1], vec![ch('e'), ch('f'), ch('g'), ch('h')]);
assert_eq!(rows[2], vec![ch('i'), ch('j'), Cell::Empty, Cell::Empty]);
}
#[test]
fn chop_long_line_truncates() {
let rows = render_line(b"abcdefghij", &opts(4, false), None);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0], vec![ch('a'), ch('b'), ch('c'), ch('d')]);
}
#[test]
fn wide_char_at_boundary_pushed_to_next_row() {
let rows = render_line("ab日".as_bytes(), &opts(3, true), None);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], vec![ch('a'), ch('b'), Cell::Empty]);
assert_eq!(
rows[1][0],
Cell::Char { ch: '日', width: 2, style: crate::ansi::Style::default(), hyperlink: None }
);
assert_eq!(rows[1][1], Cell::Continuation);
assert_eq!(rows[1][2], Cell::Empty);
}
#[test]
fn count_rows_matches_render_line_for_short() {
let o = opts(80, true);
let bytes = b"hello world";
assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
}
#[test]
fn count_rows_matches_render_line_for_long_wrap() {
let o = opts(4, true);
let bytes = b"abcdefghij";
assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
}
#[test]
fn count_rows_chop_is_one() {
let o = opts(4, false);
let bytes = b"abcdefghij";
assert_eq!(count_rows(bytes, &o, None), 1);
}
#[test]
fn count_rows_handles_wide_char() {
let o = opts(3, true);
let bytes = "ab日".as_bytes();
assert_eq!(count_rows(bytes, &o, None), render_line(bytes, &o, None).len());
}
fn interpret_opts() -> RenderOpts {
RenderOpts { mode: AnsiMode::Interpret, ..Default::default() }
}
#[test]
fn interpret_red_text() {
let mut state = RenderState::default();
let rows = render_line(b"\x1b[31mhi", &interpret_opts(), Some(&mut state));
let cells: Vec<&Cell> =
rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
assert_eq!(cells.len(), 2);
for c in cells {
if let Cell::Char { style, .. } = c {
assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
}
}
}
#[test]
fn interpret_truecolor() {
let mut state = RenderState::default();
let rows =
render_line(b"\x1b[38;2;255;0;0mfoo", &interpret_opts(), Some(&mut state));
let cells: Vec<&Cell> =
rows.iter().flatten().filter(|c| matches!(c, Cell::Char { .. })).collect();
for c in cells {
if let Cell::Char { style, .. } = c {
assert_eq!(style.fg, Some(crate::ansi::Color::Rgb(255, 0, 0)));
}
}
}
#[test]
fn interpret_wide_char_carries_color() {
let mut state = RenderState::default();
let rows =
render_line("\x1b[31m日".as_bytes(), &interpret_opts(), Some(&mut state));
let jp_cell = rows.iter().flatten().find_map(|c| match c {
Cell::Char { ch: '日', style, width, .. } => Some((style, *width)),
_ => None,
});
let (style, width) = jp_cell.expect("expected 日 cell");
assert_eq!(style.fg, Some(crate::ansi::Color::Ansi(1)));
assert_eq!(width, 2);
}
#[test]
fn interpret_state_persists_across_calls() {
let mut state = RenderState::default();
let _ = render_line(b"\x1b[31mline1", &interpret_opts(), Some(&mut state));
let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
let l_cell = rows.iter().flatten().find_map(|c| match c {
Cell::Char { ch: 'l', style, .. } => Some(style),
_ => None,
});
assert_eq!(
l_cell.expect("expected l cell").fg,
Some(crate::ansi::Color::Ansi(1))
);
}
#[test]
fn interpret_reset_clears_state() {
let mut state = RenderState::default();
let _ =
render_line(b"\x1b[31mline1\x1b[0m", &interpret_opts(), Some(&mut state));
let rows = render_line(b"line2", &interpret_opts(), Some(&mut state));
let l_cell = rows.iter().flatten().find_map(|c| match c {
Cell::Char { ch: 'l', style, .. } => Some(style),
_ => None,
});
assert_eq!(l_cell.expect("expected l cell"), &crate::ansi::Style::default());
}
#[test]
fn interpret_non_sgr_csi_is_zero_width() {
let mut state = RenderState::default();
let rows = render_line(b"\x1b[2Jdata", &interpret_opts(), Some(&mut state));
let chars: String = rows
.iter()
.flatten()
.filter_map(|c| match c {
Cell::Char { ch, .. } => Some(*ch),
_ => None,
})
.collect();
assert_eq!(chars, "data");
}
#[test]
fn strict_mode_esc_still_renders_as_caret_lbracket() {
let rows = render_line(b"\x1b[31mhi", &RenderOpts::default(), None);
let chars: String = rows
.iter()
.flatten()
.filter_map(|c| match c {
Cell::Char { ch, .. } => Some(*ch),
_ => None,
})
.collect();
assert!(chars.starts_with("^["), "got: {chars:?}");
}
#[test]
fn osc8_hyperlink_attached_to_cells() {
let mut state = RenderState::default();
let rows = render_line(
b"\x1b]8;;https://example.com\x07click\x1b]8;;\x07",
&interpret_opts(),
Some(&mut state),
);
let click_cell = rows.iter().flatten().find_map(|c| match c {
Cell::Char { ch: 'c', hyperlink, .. } => Some(hyperlink.clone()),
_ => None,
});
let link = click_cell.expect("expected c cell").expect("expected hyperlink");
assert_eq!(link.as_ref(), "https://example.com");
}
}