use std::rc::Rc;
use compact_str::CompactString;
use crate::text::ansi_tokenize::{
AnsiToken, StyledChar, empty_styles, styled_chars_from_plain, styled_chars_from_tokens,
styled_chars_to_string_into, tokenize,
};
use crate::text::slice_ansi::slice_ansi;
use crate::text::string_width::string_width;
pub type Transformer<'a> = &'a dyn Fn(&str, usize) -> String;
#[derive(Debug, Clone, Copy, Default)]
pub struct Clip {
pub x1: Option<i32>,
pub x2: Option<i32>,
pub y1: Option<i32>,
pub y2: Option<i32>,
}
#[derive(Debug, Clone)]
struct Cell {
ch: StyledChar,
hole: bool,
}
impl Cell {
fn space() -> Self {
Self {
ch: StyledChar {
value: CompactString::const_new(" "),
full_width: false,
styles: empty_styles(),
},
hole: false,
}
}
fn placeholder(styles: Rc<[AnsiToken]>) -> Self {
Self {
ch: StyledChar {
value: CompactString::const_new(""),
full_width: false,
styles,
},
hole: false,
}
}
fn hole() -> Self {
Self {
ch: StyledChar {
value: CompactString::const_new(""),
full_width: false,
styles: empty_styles(),
},
hole: true,
}
}
fn is_placeholder(&self) -> bool {
!self.hole && self.ch.value.is_empty()
}
}
pub struct Grid {
rows: usize,
cells: Vec<Vec<Cell>>,
clip_stack: Vec<Clip>,
}
impl Grid {
pub fn new(rows: usize, cols: usize) -> Self {
let cells = (0..rows)
.map(|_| (0..cols).map(|_| Cell::space()).collect())
.collect();
Self {
rows,
cells,
clip_stack: Vec::new(),
}
}
pub fn push_clip(&mut self, clip: Clip) {
self.clip_stack.push(clip);
}
pub fn pop_clip(&mut self) {
self.clip_stack.pop();
}
pub fn write(&mut self, x: i32, y: i32, text: &str) {
self.write_styled(x, y, text, &[]);
}
pub fn write_styled(&mut self, x: i32, y: i32, text: &str, transformers: &[Transformer<'_>]) {
if text.is_empty() {
return;
}
let clip = self.clip_stack.last().copied();
let mut lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
if let Some(clip) = clip {
let clip_h = clip.x1.is_some() && clip.x2.is_some();
let clip_v = clip.y1.is_some() && clip.y2.is_some();
if clip_h {
let w = lines
.iter()
.map(|l| string_width(l) as i32)
.max()
.unwrap_or(0);
let x1 = clip.x1.unwrap();
let x2 = clip.x2.unwrap();
if x + w < x1 || x > x2 {
return;
}
}
if clip_v {
let height = lines.len() as i32;
let y1 = clip.y1.unwrap();
let y2 = clip.y2.unwrap();
if y + height < y1 || y > y2 {
return;
}
}
}
let mut eff_x = x;
let mut eff_y = y;
if let Some(clip) = clip {
let clip_h = clip.x1.is_some() && clip.x2.is_some();
let clip_v = clip.y1.is_some() && clip.y2.is_some();
if clip_h {
let x1 = clip.x1.unwrap();
let x2 = clip.x2.unwrap();
lines = lines
.iter()
.map(|line| {
let from = if x < x1 { (x1 - x) as usize } else { 0 };
let line_w = string_width(line) as i32;
let to = if x + line_w > x2 {
(x2 - x) as usize
} else {
line_w as usize
};
slice_ansi(line, from, Some(to))
})
.collect();
if x < x1 {
eff_x = x1;
}
}
if clip_v {
let y1 = clip.y1.unwrap();
let y2 = clip.y2.unwrap();
let from = if eff_y < y1 { (y1 - eff_y) as usize } else { 0 };
let height = lines.len() as i32;
let to = if eff_y + height > y2 {
(y2 - eff_y) as usize
} else {
lines.len()
};
let to = to.min(lines.len());
lines = lines[from.min(to)..to].to_vec();
if eff_y < y1 {
eff_y = y1;
}
}
}
for (line_idx, line) in lines.iter().enumerate() {
let row_y = eff_y + line_idx as i32;
if row_y < 0 || row_y as usize >= self.rows {
continue; }
let mut transformed = line.clone();
for transformer in transformers {
transformed = transformer(&transformed, line_idx);
}
let chars = if transformed.contains(['\u{1B}', '\u{9B}']) {
styled_chars_from_tokens(&tokenize(&transformed, None))
} else {
styled_chars_from_plain(&transformed)
};
if chars.is_empty() {
continue;
}
let row = &mut self.cells[row_y as usize];
let mut offset_x = eff_x;
if offset_x > 0 {
let col = offset_x as usize;
if col < row.len()
&& row[col].is_placeholder()
&& string_width(&row[col - 1].ch.value) > 1
{
row[col - 1] = Cell::space();
}
}
for ch in chars {
let char_w = string_width(&ch.value).max(1);
if offset_x < 0 {
offset_x += char_w as i32;
continue;
}
let col = offset_x as usize;
grow_to(row, col);
if char_w > 1 {
let lead_styles = Rc::clone(&ch.styles);
row[col] = Cell { ch, hole: false };
for extra in 1..char_w {
let next_col = col + extra;
grow_to(row, next_col);
row[next_col] = Cell::placeholder(Rc::clone(&lead_styles));
}
} else {
row[col] = Cell { ch, hole: false };
}
offset_x += char_w as i32;
}
let after = offset_x as usize;
if after < row.len() && row[after].is_placeholder() {
row[after] = Cell::space();
}
}
}
pub fn get(&self) -> (String, usize) {
let mut output = String::new();
for (row_idx, row) in self.cells.iter().enumerate() {
if row_idx != 0 {
output.push('\n');
}
let row_start = output.len();
let survivors = row.iter().filter(|c| !c.hole).map(|c| &c.ch);
styled_chars_to_string_into(survivors, &mut output);
let trimmed_len = output[row_start..]
.trim_end_matches(is_js_trim_end_whitespace)
.len();
output.truncate(row_start + trimmed_len);
}
(output, self.rows)
}
}
#[cfg(test)]
impl Grid {
pub fn cell_content(&self, row: usize, col: usize) -> &str {
&self.cells[row][col].ch.value
}
pub fn cell_styles(&self, row: usize, col: usize) -> &[crate::text::ansi_tokenize::AnsiToken] {
&self.cells[row][col].ch.styles
}
}
fn is_js_trim_end_whitespace(c: char) -> bool {
c == '\u{FEFF}' || (c != '\u{0085}' && c.is_whitespace())
}
fn grow_to(row: &mut Vec<Cell>, col: usize) {
while row.len() <= col {
row.push(Cell::hole());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_grid_trims_to_empty_lines() {
let g = Grid::new(3, 5);
let (out, h) = g.get();
assert_eq!(h, 3);
assert_eq!(out, "\n\n");
}
#[test]
fn transformer_chain_own_before_inherited_with_index() {
let inner = |s: &str, i: usize| format!("I{i}{{{s}}}");
let outer = |s: &str, i: usize| format!("O{i}{{{s}}}");
let chain: [Transformer<'_>; 2] = [&inner, &outer];
let mut g = Grid::new(2, 40);
g.write_styled(0, 0, "ab\ncd", &chain);
let (out, _) = g.get();
assert_eq!(out, "O0{I0{ab}}\nO1{I1{cd}}");
}
#[test]
fn wide_char_placeholder_inherits_lead_styles() {
let mut g = Grid::new(1, 6);
g.write(0, 0, "\x1b[31m中\x1b[39m");
assert_eq!(g.cell_content(0, 0), "中");
let lead_styles = g.cell_styles(0, 0).to_vec();
assert_eq!(lead_styles.len(), 1, "lead carries the red SGR");
assert_eq!(lead_styles[0].code, "\x1b[31m");
assert_eq!(g.cell_content(0, 1), "", "col 1 is a wide-char placeholder");
assert_eq!(
g.cell_styles(0, 1),
lead_styles.as_slice(),
"placeholder inherits the lead char's styles (output.ts:288)"
);
}
#[test]
fn styled_trailing_spaces_trim_byte_identical() {
let red = |s: &str, _i: usize| format!("\x1b[31m{s}\x1b[39m");
let chain: [Transformer<'_>; 1] = [&red];
let mut g = Grid::new(1, 10);
g.write_styled(0, 0, "Hi", &chain); let (out, _) = g.get();
assert_eq!(out, "\x1b[31mHi\x1b[39m");
}
#[test]
fn styled_trailing_space_survives_trim() {
let mut g = Grid::new(1, 10);
g.write(0, 0, "AB\x1b[7m \x1b[27m");
let (out, _) = g.get();
assert_eq!(
out, "AB\x1b[7m \x1b[27m",
"style-bearing trailing space survives; unstyled pad is trimmed"
);
let mut g2 = Grid::new(1, 10);
g2.write(0, 0, "AB\x1b[7m \x1b[27m ");
let (out2, _) = g2.get();
assert_eq!(
out2, "AB\x1b[7m \x1b[27m",
"unstyled spaces after the styled space are trimmed exactly as before"
);
}
#[test]
fn row_tail_trims_full_js_trim_end_set() {
let cases: [(&str, &str); 6] = [
("AB\t", "tab"),
("AB\u{a0}", "nbsp"),
("AB\u{3000}", "ideographic space"),
("AB\u{2009}", "thin space"),
("AB \t", "mixed space+tab tail"),
(
"AB\u{feff}",
"ZWNBSP/BOM (JS trims; Rust is_whitespace does NOT)",
),
];
for (text, name) in cases {
let mut g = Grid::new(1, 20);
g.write(0, 0, text);
let (out, _) = g.get();
assert_eq!(out, "AB", "oracle trims the {name} tail to \"AB\"");
}
}
#[test]
fn row_tail_keeps_non_js_whitespace() {
let cases: [(&str, &str, &str); 2] = [
("AB\u{200b}", "AB\u{200b}", "ZWSP"),
("AB\u{85}", "AB\u{85}", "NEL (C1; JS trimEnd keeps it)"),
];
for (text, expected, name) in cases {
let mut g = Grid::new(1, 20);
g.write(0, 0, text);
let (out, _) = g.get();
assert_eq!(out, expected, "{name} tail is NOT JS whitespace — kept");
}
}
#[test]
fn styled_trailing_nbsp_survives_trim() {
let mut g = Grid::new(1, 10);
g.write(0, 0, "AB\x1b[7m\u{a0}\x1b[27m");
let (out, _) = g.get();
assert_eq!(
out, "AB\x1b[7m\u{a0}\x1b[27m",
"style-bearing trailing NBSP survives; unstyled pad is trimmed"
);
}
#[test]
fn write_simple_text() {
let mut g = Grid::new(2, 10);
g.write(0, 0, "Hi");
let (out, _) = g.get();
let lines: Vec<&str> = out.split('\n').collect();
assert_eq!(lines[0], "Hi");
}
#[test]
fn write_at_offset_x() {
let mut g = Grid::new(1, 10);
g.write(2, 0, "AB");
let (out, _) = g.get();
assert_eq!(out, " AB");
}
#[test]
fn write_multiline() {
let mut g = Grid::new(3, 10);
g.write(0, 0, "line1\nline2");
let (out, _) = g.get();
let lines: Vec<&str> = out.split('\n').collect();
assert_eq!(lines[0], "line1");
assert_eq!(lines[1], "line2");
}
#[test]
fn wide_char_leader_blanked_on_overwrite() {
let mut g = Grid::new(1, 6);
g.write(0, 0, "中"); g.write(1, 0, "X"); let (out, _) = g.get();
assert_eq!(&out[..2], " X");
}
#[test]
fn wide_char_trailer_blanked_after_write() {
let mut g = Grid::new(1, 8);
g.write(2, 0, "中"); g.write(2, 0, "Y");
assert_eq!(
g.cell_content(0, 2),
"Y",
"col 2 must hold the written char"
);
assert_eq!(
g.cell_content(0, 3),
" ",
"col 3 placeholder must be blanked to space"
);
}
#[test]
fn wide_char_clipped_at_boundary() {
let mut g = Grid::new(1, 8);
g.push_clip(Clip {
x1: Some(0),
x2: Some(5),
y1: None,
y2: None,
});
g.write(4, 0, "中"); g.pop_clip();
let (out, _) = g.get();
assert!(!out.contains('\0'), "no null bytes");
let cells: Vec<&str> = g.cells[0].iter().map(|c| c.ch.value.as_str()).collect();
assert!(
!cells[5].is_empty() || cells[4] != "中",
"no dangling wide-char placeholder at boundary"
);
}
#[test]
fn clip_horizontal_skips_entirely_outside() {
let mut g = Grid::new(1, 20);
g.push_clip(Clip {
x1: Some(5),
x2: Some(10),
y1: None,
y2: None,
});
g.write(15, 0, "hello"); g.pop_clip();
let (out, _) = g.get();
assert_eq!(out, ""); }
#[test]
fn clip_horizontal_trims_left() {
let mut g = Grid::new(1, 20);
g.push_clip(Clip {
x1: Some(3),
x2: Some(10),
y1: None,
y2: None,
});
g.write(1, 0, "ABCDE"); g.pop_clip();
let (out, _) = g.get();
let expected: String = " CDE".to_owned(); assert_eq!(out, expected);
}
#[test]
fn clip_vertical_skips_rows_outside() {
let mut g = Grid::new(4, 10);
g.push_clip(Clip {
x1: None,
x2: None,
y1: Some(1),
y2: Some(2),
});
g.write(0, 0, "row0\nrow1\nrow2\nrow3"); g.pop_clip();
let (out, _) = g.get();
let lines: Vec<&str> = out.split('\n').collect();
assert_eq!(lines[0], ""); assert_eq!(lines[1], "row1"); assert_eq!(lines[2], ""); assert_eq!(lines[3], ""); }
#[test]
fn clip_push_pop_restores() {
let mut g = Grid::new(1, 20);
g.push_clip(Clip {
x1: Some(5),
x2: Some(10),
y1: None,
y2: None,
});
g.pop_clip();
g.write(0, 0, "hello");
let (out, _) = g.get();
assert!(out.starts_with("hello"));
}
#[test]
fn get_trims_trailing_spaces() {
let mut g = Grid::new(1, 10);
g.write(0, 0, "Hi"); let (out, _) = g.get();
assert_eq!(out, "Hi");
}
#[test]
fn get_all_spaces_trims_to_empty() {
let g = Grid::new(1, 5);
let (out, _) = g.get();
assert_eq!(out, "");
}
#[test]
fn write_negative_x_clips_left_edge() {
let mut g = Grid::new(1, 5);
g.write(-1, 0, "AB");
assert_eq!(g.cell_content(0, 0), "B", "B must land at col 0");
assert_eq!(g.cell_content(0, 1), " ", "col 1 must stay space");
}
#[test]
fn write_over_width_grows_row_no_right_clip() {
let mut g = Grid::new(1, 3);
g.write(0, 0, "ABCDE"); assert_eq!(g.cell_content(0, 0), "A");
assert_eq!(g.cell_content(0, 4), "E");
let (out, _) = g.get();
assert_eq!(out, "ABCDE");
}
#[test]
fn off_grid_overlap_last_writer_wins() {
let mut g = Grid::new(1, 6);
g.write(0, 0, "ABCDEF");
g.write(2, 0, "xy"); let (out, _) = g.get();
assert_eq!(out, "ABxyEF");
}
#[test]
fn jagged_oob_row_shape_byte_match() {
let mut g = Grid::new(3, 10);
g.write(0, 0, "╭──────────╮"); g.write(0, 1, "│"); g.write(11, 1, "│"); g.write(0, 2, "╰──────────╯"); let (out, _) = g.get();
let lines: Vec<&str> = out.split('\n').collect();
assert_eq!(lines[0], "╭──────────╮", "top row materializes 12 cols");
assert_eq!(
lines[1], "│ │",
"interior row drops the col-10 hole → 11 cols (│ + 9 spaces + │)"
);
assert_eq!(lines[2], "╰──────────╯", "bottom row materializes 12 cols");
assert_eq!(lines[1].chars().count(), 11);
assert_eq!(lines[0].chars().count(), 12);
}
#[test]
fn innermost_clip_is_top_of_stack_after_pop() {
let mut g = Grid::new(2, 10);
g.push_clip(Clip {
x1: Some(0),
x2: Some(8),
y1: None,
y2: None,
});
g.push_clip(Clip {
x1: Some(0),
x2: Some(2),
y1: None,
y2: None,
});
g.write(0, 0, "ABCDEFGH"); g.pop_clip();
g.write(0, 1, "ABCDEFGH"); g.pop_clip();
let (out, _) = g.get();
let lines: Vec<&str> = out.split('\n').collect();
assert_eq!(lines[0], "AB", "under inner clip {{0,2}}");
assert_eq!(
lines[1], "ABCDEFGH",
"after pop, outer {{0,8}} alone is the active clip"
);
}
#[test]
fn innermost_clip_wider_than_ancestor_wins() {
let mut g = Grid::new(1, 10);
g.push_clip(Clip {
x1: Some(0),
x2: Some(2),
y1: None,
y2: None,
});
g.push_clip(Clip {
x1: Some(0),
x2: Some(8),
y1: None,
y2: None,
});
g.write(0, 0, "ABCDEFGH");
g.pop_clip();
g.pop_clip();
let (out, _) = g.get();
assert_eq!(
out, "ABCDEFGH",
"innermost {{0,8}} wins over wider ancestor {{0,2}}"
);
}
#[test]
fn clip_y_inverted_partial_span_writes_nothing() {
let mut g = Grid::new(5, 10);
g.push_clip(Clip {
x1: None,
x2: None,
y1: Some(2),
y2: Some(1),
});
g.write(0, 1, "aa\nbb\ncc");
g.pop_clip();
let (out, h) = g.get();
assert_eq!(h, 5);
assert_eq!(out, "\n\n\n\n");
}
#[test]
fn clip_x_inverted_partial_span_writes_nothing() {
let mut g = Grid::new(5, 10);
g.push_clip(Clip {
x1: Some(5),
x2: Some(2),
y1: None,
y2: None,
});
g.write(2, 0, "abcd");
g.pop_clip();
let (out, _) = g.get();
assert_eq!(out, "\n\n\n\n");
}
#[test]
fn clip_fully_outside_grid_writes_nothing() {
let mut g = Grid::new(5, 10);
g.push_clip(Clip {
x1: None,
x2: None,
y1: Some(100),
y2: Some(200),
});
g.write(0, 99, "aa\nbb\ncc");
g.pop_clip();
let (out, _) = g.get();
assert_eq!(out, "\n\n\n\n");
}
#[test]
fn clip_zero_area_writes_nothing() {
let mut g = Grid::new(5, 10);
g.push_clip(Clip {
x1: Some(2),
x2: Some(2),
y1: None,
y2: None,
});
g.write(0, 0, "abcd");
g.pop_clip();
assert_eq!(g.get().0, "\n\n\n\n", "zero-area x clip");
let mut g = Grid::new(5, 10);
g.push_clip(Clip {
x1: None,
x2: None,
y1: Some(1),
y2: Some(1),
});
g.write(0, 0, "aa\nbb\ncc");
g.pop_clip();
assert_eq!(g.get().0, "\n\n\n\n", "zero-area y clip");
let mut g = Grid::new(5, 10);
g.push_clip(Clip {
x1: Some(2),
x2: Some(2),
y1: Some(1),
y2: Some(1),
});
g.write(0, 0, "abcd\nefgh\nijkl");
g.pop_clip();
assert_eq!(g.get().0, "\n\n\n\n", "zero-area x+y clip");
}
}