use super::Cell;
use super::cell::{char_display_width, is_regional_indicator};
use super::scrollback::Scrollback;
use unicode_segmentation::UnicodeSegmentation;
pub struct Grid {
cells: Vec<Vec<Cell>>,
pub cols: usize,
pub rows: usize,
cursor_col: usize,
cursor_row: usize,
wrap_pending: bool,
dirty_rows: Vec<bool>,
scroll_region_top: usize,
scroll_region_bottom: usize,
saved_col: usize,
saved_row: usize,
saved_wrap_pending: bool,
}
struct CellFragment {
text: String,
start: usize,
end: usize,
}
impl Grid {
pub fn new(cols: usize, rows: usize) -> Self {
assert!(cols > 0 && rows > 0, "grid dimensions must be positive");
Self {
cells: vec![vec![Cell::blank(); cols]; rows],
dirty_rows: vec![true; rows],
cols,
rows,
cursor_col: 0,
cursor_row: 0,
wrap_pending: false,
scroll_region_top: 0,
scroll_region_bottom: rows.saturating_sub(1),
saved_col: 0,
saved_row: 0,
saved_wrap_pending: false,
}
}
pub fn cols(&self) -> usize {
self.cols
}
pub fn rows(&self) -> usize {
self.rows
}
pub fn cursor_col(&self) -> usize {
self.cursor_col
}
pub fn cursor_row(&self) -> usize {
self.cursor_row
}
pub fn cell(&self, row: usize, col: usize) -> Option<&Cell> {
self.cells.get(row).and_then(|r| r.get(col))
}
#[inline(always)]
pub fn check_invariants(&self) {
debug_assert!(self.cols > 0, "grid must have at least 1 column");
debug_assert!(self.rows > 0, "grid must have at least 1 row");
debug_assert_eq!(self.cells.len(), self.rows, "row count mismatch");
debug_assert_eq!(
self.dirty_rows.len(),
self.rows,
"dirty_rows length mismatch"
);
for (i, row) in self.cells.iter().enumerate() {
debug_assert_eq!(row.len(), self.cols, "row {} length mismatch", i);
}
debug_assert!(
self.cursor_col < self.cols,
"cursor_col {} out of bounds (cols={})",
self.cursor_col,
self.cols
);
debug_assert!(
self.cursor_row < self.rows,
"cursor_row {} out of bounds (rows={})",
self.cursor_row,
self.rows
);
debug_assert!(
self.scroll_region_top <= self.scroll_region_bottom,
"scroll region inverted: top={} > bottom={}",
self.scroll_region_top,
self.scroll_region_bottom
);
debug_assert!(
self.scroll_region_bottom < self.rows,
"scroll region bottom {} >= rows {}",
self.scroll_region_bottom,
self.rows
);
debug_assert!(
self.saved_row < self.rows,
"saved_row {} out of bounds",
self.saved_row
);
debug_assert!(
self.saved_col < self.cols,
"saved_col {} out of bounds",
self.saved_col
);
}
fn debug_check_char(c: char) {
debug_assert!(
!c.is_control() || c == '\n' || c == '\t' || c == '\r',
"invalid control character in cell: {:?} (u+{:04X})",
c,
c as u32
);
}
pub fn is_row_dirty(&self, row: usize) -> bool {
self.dirty_rows.get(row).copied().unwrap_or(true)
}
pub fn clear_dirty(&mut self) {
for f in &mut self.dirty_rows {
*f = false;
}
}
pub fn mark_all_dirty(&mut self) {
for f in &mut self.dirty_rows {
*f = true;
}
}
pub fn row_cells(&self, row: usize) -> &[Cell] {
if row >= self.rows {
&[]
} else {
&self.cells[row]
}
}
pub fn row_text(&self, row: usize) -> String {
if row >= self.rows {
return String::new();
}
let mut s = String::with_capacity(self.cols);
for cell in &self.cells[row] {
cell.push_text(&mut s);
}
s
}
fn mark_dirty(&mut self, row: usize) {
if row < self.rows {
self.dirty_rows[row] = true;
}
}
fn reset_row_to_blank(&mut self, row: usize) {
self.reset_row_to_blank_with(row, &Cell::blank());
}
fn reset_row_to_blank_with(&mut self, row: usize, blank: &Cell) {
if row < self.rows {
self.cells[row].fill(blank_like(blank));
}
}
fn flush_wrap_pending(&mut self, scrollback: Option<&mut Scrollback>) {
self.flush_wrap_pending_with(scrollback, &Cell::blank());
}
fn flush_wrap_pending_with(&mut self, scrollback: Option<&mut Scrollback>, blank: &Cell) {
if self.wrap_pending {
self.newline_with(scrollback, blank);
}
}
fn advance_after_print(&mut self, width: usize) {
let next_col = self.cursor_col + width;
if next_col >= self.cols {
self.cursor_col = self.cols - 1;
self.wrap_pending = true;
} else {
self.cursor_col = next_col;
self.wrap_pending = false;
}
}
pub fn put_cell(&mut self, mut cell: Cell, mut scrollback: Option<&mut Scrollback>) {
self.check_invariants();
Self::debug_check_char(cell.c);
if cell.display_width() == 0 {
self.append_combining(cell);
return;
}
if self.should_join_previous_cluster(cell.c) {
self.append_to_previous_cluster(cell);
return;
}
let blank = Cell::blank_with_attrs(cell.fg, cell.bg, cell.attrs);
self.flush_wrap_pending_with(scrollback.as_deref_mut(), &blank);
let width = cell.display_width().min(self.cols);
cell.width = width as u8;
if width > 1 && self.cursor_col + width > self.cols {
self.newline_with(scrollback, &blank);
}
if self.cursor_row < self.rows && self.cursor_col < self.cols {
if is_plain_space(&cell) {
let row = self.cursor_row;
let col = self.cursor_col;
self.blank_columns_for_space_overwrite(row, col, col + width);
self.cells[row][col] = cell;
self.mark_dirty(row);
self.advance_after_print(width);
return;
}
if width == 1
&& !self.cells[self.cursor_row][self.cursor_col].continuation
&& self.cells[self.cursor_row][self.cursor_col].width <= 1
{
self.cells[self.cursor_row][self.cursor_col] = cell;
self.mark_dirty(self.cursor_row);
self.advance_after_print(1);
return;
}
for offset in 0..width {
self.clear_cell_for_overwrite(self.cursor_row, self.cursor_col + offset);
}
self.cells[self.cursor_row][self.cursor_col] = cell;
for offset in 1..width {
let continuation =
Cell::continuation_of(&self.cells[self.cursor_row][self.cursor_col]);
self.cells[self.cursor_row][self.cursor_col + offset] = continuation;
}
self.mark_dirty(self.cursor_row);
}
self.advance_after_print(width);
}
pub fn put_ascii_bytes(
&mut self,
bytes: &[u8],
fg: super::cell::CellColor,
bg: super::cell::CellColor,
attrs: super::cell::CellAttrs,
hyperlink_id: u32,
mut scrollback: Option<&mut Scrollback>,
) {
self.check_invariants();
let mut offset = 0usize;
while offset < bytes.len() {
let blank = Cell::blank_with_attrs(fg, bg, attrs);
self.flush_wrap_pending_with(scrollback.as_deref_mut(), &blank);
if self.cursor_row >= self.rows || self.cursor_col >= self.cols {
break;
}
let writable = (self.cols - self.cursor_col).min(bytes.len() - offset);
let row = self.cursor_row;
let col = self.cursor_col;
if self.ascii_run_can_fast_overwrite(row, col, col + writable) {
for (idx, byte) in bytes[offset..offset + writable].iter().copied().enumerate() {
self.cells[row][col + idx] =
Cell::ascii_with_attrs(byte, fg, bg, attrs).with_hyperlink_id(hyperlink_id);
}
self.mark_dirty(row);
offset += writable;
self.advance_after_print(writable);
} else {
let byte = bytes[offset];
self.put_cell(
Cell::ascii_with_attrs(byte, fg, bg, attrs).with_hyperlink_id(hyperlink_id),
scrollback.as_deref_mut(),
);
offset += 1;
}
}
}
fn ascii_run_can_fast_overwrite(&self, row: usize, start: usize, end: usize) -> bool {
debug_assert!(row < self.rows);
debug_assert!(start <= end);
debug_assert!(end <= self.cols);
if start == end {
return false;
}
if self.cells[row][start].continuation {
return false;
}
end >= self.cols || !self.cells[row][end].continuation
}
fn append_combining(&mut self, cell: Cell) {
let Some((row, col)) = self.previous_printable_cell() else {
if self.cursor_row < self.rows && self.cursor_col < self.cols {
let mut cell = cell;
cell.width = 1;
self.cells[self.cursor_row][self.cursor_col] = cell;
self.mark_dirty(self.cursor_row);
self.advance_after_print(1);
}
return;
};
self.cells[row][col].append_combining(cell.c);
self.mark_dirty(row);
}
fn should_join_previous_cluster(&self, c: char) -> bool {
let Some((row, col)) = self.previous_printable_cell() else {
return false;
};
let previous = &self.cells[row][col];
previous.text_ends_with_zwj()
|| (previous.is_single_regional_indicator() && is_regional_indicator(c))
|| self.forms_single_grapheme(previous, c)
}
fn forms_single_grapheme(&self, previous: &Cell, c: char) -> bool {
if previous.continuation || previous.c.is_ascii() && c.is_ascii() {
return false;
}
let mut candidate = previous.text();
candidate.push(c);
candidate.graphemes(true).take(2).count() == 1
}
fn append_to_previous_cluster(&mut self, cell: Cell) {
let Some((row, col)) = self.previous_printable_cell() else {
return;
};
let joined_regional_pair;
{
let previous = &mut self.cells[row][col];
joined_regional_pair =
previous.is_single_regional_indicator() && is_regional_indicator(cell.c);
previous.append_to_cluster(cell.c);
if let Some(extra) = cell.extra {
for c in extra.chars() {
previous.append_to_cluster(c);
}
}
if previous.width == 0 {
previous.width = 1;
}
if joined_regional_pair {
previous.width = 2;
}
let max_width = (self.cols - col).min(u8::MAX as usize) as u8;
previous.width = previous.width.min(max_width);
}
let width = self.cells[row][col].display_width();
for offset in 1..width {
self.cells[row][col + offset] = Cell::continuation_of(&self.cells[row][col]);
}
if self.cursor_row == row && self.cursor_col < col + width {
if col + width >= self.cols {
self.cursor_col = self.cols - 1;
self.wrap_pending = true;
} else {
self.cursor_col = col + width;
self.wrap_pending = false;
}
}
self.mark_dirty(row);
}
fn previous_printable_cell(&self) -> Option<(usize, usize)> {
if self.rows == 0 || self.cols == 0 {
return None;
}
let mut row = self.cursor_row.min(self.rows - 1);
let mut col = if self.wrap_pending {
self.cols
} else {
self.cursor_col.min(self.cols)
};
if col == 0 {
if row == 0 {
return None;
}
row -= 1;
col = self.cols;
}
while col > 0 {
col -= 1;
let cell = &self.cells[row][col];
if !cell.continuation {
return Some((row, col));
}
}
None
}
fn clear_cell_for_overwrite(&mut self, row: usize, col: usize) {
if row >= self.rows || col >= self.cols {
return;
}
if self.cells[row][col].continuation {
let mut start = col;
while start > 0 && self.cells[row][start].continuation {
start -= 1;
}
if start != col {
self.clear_cell_for_overwrite(row, start);
}
return;
}
let width = self.cells[row][col].display_width();
for offset in 1..width {
if col + offset < self.cols {
self.cells[row][col + offset] = Cell::blank();
}
}
self.cells[row][col] = Cell::blank();
}
fn blank_columns_for_space_overwrite(&mut self, row: usize, start: usize, end: usize) {
if row >= self.rows || start >= end || start >= self.cols {
return;
}
let end = end.min(self.cols);
let mut leaders = Vec::new();
let mut col = start;
while col < end {
let leader = self.cluster_start_at(row, col);
if leaders.last().copied() != Some(leader) {
leaders.push(leader);
}
let width = self.cells[row][leader].display_width().max(1);
col = (leader + width).max(col + 1);
}
for leader in leaders {
self.rewrite_cluster_after_space_overwrite(row, leader, start, end);
}
}
fn cluster_start_at(&self, row: usize, col: usize) -> usize {
let mut start = col.min(self.cols.saturating_sub(1));
while start > 0 && self.cells[row][start].continuation {
start -= 1;
}
start
}
fn rewrite_cluster_after_space_overwrite(
&mut self,
row: usize,
leader: usize,
erase_start: usize,
erase_end: usize,
) {
if row >= self.rows || leader >= self.cols {
return;
}
let leader = self.cluster_start_at(row, leader);
let original = self.cells[row][leader].clone();
let width = original.display_width().max(1);
let cluster_end = (leader + width).min(self.cols);
let erase_start = erase_start.max(leader).min(cluster_end);
let erase_end = erase_end.min(cluster_end).max(erase_start);
if erase_start >= erase_end {
return;
}
let fragments = cell_fragments(&original, leader, cluster_end);
for col in leader..cluster_end {
self.cells[row][col] = Cell::blank();
}
for fragment in fragments {
if fragment.end <= erase_start || fragment.start >= erase_end {
self.write_fragment(row, &original, fragment);
}
}
}
fn write_fragment(&mut self, row: usize, template: &Cell, fragment: CellFragment) {
if row >= self.rows || fragment.start >= self.cols || fragment.text.is_empty() {
return;
}
let width = (fragment.end - fragment.start)
.max(1)
.min(self.cols - fragment.start);
let mut chars = fragment.text.chars();
let Some(first) = chars.next() else {
return;
};
let rest = chars.as_str();
let cell = Cell {
c: first,
extra: (!rest.is_empty()).then(|| rest.to_string().into_boxed_str()),
fg: template.fg,
bg: template.bg,
attrs: template.attrs,
width: width as u8,
continuation: false,
hyperlink_id: template.hyperlink_id,
};
self.cells[row][fragment.start] = cell;
for offset in 1..width {
self.cells[row][fragment.start + offset] =
Cell::continuation_of(&self.cells[row][fragment.start]);
}
}
fn erase_row_range(&mut self, row: usize, start: usize, end: usize) {
self.erase_row_range_with(row, start, end, &Cell::blank());
}
fn erase_row_range_with(&mut self, row: usize, start: usize, end: usize, blank: &Cell) {
if row >= self.rows || start >= end || start >= self.cols {
return;
}
let mut erase_start = start;
let mut erase_end = end.min(self.cols);
while erase_start > 0 && self.cells[row][erase_start].continuation {
erase_start -= 1;
}
if erase_end < self.cols && erase_end > 0 && self.cells[row][erase_end].continuation {
while erase_end < self.cols && self.cells[row][erase_end].continuation {
erase_end += 1;
}
} else if erase_end < self.cols && erase_end > erase_start {
let last = erase_end - 1;
if !self.cells[row][last].continuation {
erase_end = (last + self.cells[row][last].display_width().max(1)).min(self.cols);
}
}
for c in erase_start..erase_end {
self.cells[row][c] = blank_like(blank);
}
}
pub fn newline(&mut self, scrollback: Option<&mut Scrollback>) {
self.newline_with(scrollback, &Cell::blank());
}
pub fn newline_with(&mut self, scrollback: Option<&mut Scrollback>, blank: &Cell) {
self.wrap_pending = false;
self.cursor_col = 0;
self.check_invariants();
let bot = self.scroll_region_bottom;
if self.cursor_row >= bot {
self.scroll_up_in_region_with(1, self.scroll_region_top, bot, scrollback, blank);
} else {
self.cursor_row += 1;
}
}
pub fn carriage_return(&mut self) {
self.check_invariants();
self.wrap_pending = false;
self.cursor_col = 0;
}
pub fn backspace(&mut self) {
self.check_invariants();
if self.wrap_pending {
self.wrap_pending = false;
return;
}
if self.cursor_col > 0 {
self.cursor_col -= 1;
}
}
pub fn tab(&mut self) {
self.check_invariants();
self.wrap_pending = false;
const TAB_STOP: usize = 8;
self.cursor_col = (((self.cursor_col / TAB_STOP) + 1) * TAB_STOP).min(self.cols - 1);
}
pub fn set_scroll_region(&mut self, top_1: usize, bot_1: usize) {
self.check_invariants();
if top_1 == 0 && bot_1 == 0 {
self.scroll_region_top = 0;
self.scroll_region_bottom = self.rows.saturating_sub(1);
} else {
let t = (top_1.saturating_sub(1)).min(self.rows.saturating_sub(1));
let b = (bot_1.saturating_sub(1))
.min(self.rows.saturating_sub(1))
.max(t);
self.scroll_region_top = t;
self.scroll_region_bottom = b;
}
self.cursor_position(0, 0);
}
fn scroll_up_in_region_with(
&mut self,
count: usize,
top: usize,
bot: usize,
scrollback: Option<&mut Scrollback>,
blank: &Cell,
) {
let range_rows = bot - top + 1;
let count = count.min(range_rows);
if count == 0 {
return;
}
if count == 1 {
let evicted = self.cells.remove(top);
let recycled = if let Some(sb) = scrollback {
sb.push_owned_recycling(evicted)
} else {
Some(evicted)
};
let mut blank_row = recycled
.filter(|row| row.len() == self.cols)
.unwrap_or_else(|| vec![Cell::blank(); self.cols]);
blank_row.fill(blank_like(blank));
self.cells.insert(bot, blank_row);
for row in top..=bot {
self.mark_dirty(row);
}
return;
}
let insert_at = bot + 1 - count;
let evicted: Vec<Vec<Cell>> = self.cells.drain(top..top + count).collect();
let mut blank_rows = Vec::with_capacity(count);
if let Some(sb) = scrollback {
for row in evicted {
if let Some(recycled) = sb.push_owned_recycling(row) {
blank_rows.push(recycled);
}
}
} else {
blank_rows = evicted;
}
for idx in 0..count {
self.cells.insert(
insert_at + idx,
blank_recycled_row(blank_rows.pop(), self.cols, blank),
);
}
for row in top..=bot {
self.mark_dirty(row);
}
}
pub fn scroll_up(&mut self, count: usize, scrollback: Option<&mut Scrollback>) {
self.scroll_up_with(count, scrollback, &Cell::blank());
}
pub fn scroll_up_with(
&mut self,
count: usize,
scrollback: Option<&mut Scrollback>,
blank: &Cell,
) {
self.check_invariants();
self.wrap_pending = false;
self.scroll_up_in_region_with(
count,
self.scroll_region_top,
self.scroll_region_bottom,
scrollback,
blank,
);
}
pub fn scroll_down(&mut self, count: usize) {
self.scroll_down_with(count, &Cell::blank());
}
pub fn scroll_down_with(&mut self, count: usize, blank: &Cell) {
self.check_invariants();
self.wrap_pending = false;
let top = self.scroll_region_top;
let bot = self.scroll_region_bottom;
let range_rows = bot - top + 1;
let count = count.min(range_rows);
if count == 0 {
return;
}
if count >= range_rows {
for row in top..=bot {
self.reset_row_to_blank_with(row, blank);
self.mark_dirty(row);
}
return;
}
for row in (top + count..=bot).rev() {
let src = row - count;
for col in 0..self.cols {
self.cells[row][col] = self.cells[src][col].clone();
}
}
for row in top..top + count {
self.reset_row_to_blank_with(row, blank);
}
for row in top..=bot {
self.mark_dirty(row);
}
}
pub fn insert_lines(&mut self, n: usize) {
self.insert_lines_with(n, &Cell::blank());
}
pub fn insert_lines_with(&mut self, n: usize, blank: &Cell) {
self.check_invariants();
self.wrap_pending = false;
let top = self.cursor_row;
let bot = self.scroll_region_bottom;
if top > bot {
return;
}
let range = bot - top + 1;
let n = n.min(range);
if n == 0 {
return;
}
if n >= range {
for row in top..=bot {
self.reset_row_to_blank_with(row, blank);
self.mark_dirty(row);
}
return;
}
let end = bot - n;
for row in (top..=end).rev() {
let dst = (row + n).min(bot);
for c in 0..self.cols {
self.cells[dst][c] = self.cells[row][c].clone();
}
}
for row in top..top + n {
self.reset_row_to_blank_with(row, blank);
}
for row in top..=bot {
self.mark_dirty(row);
}
}
pub fn delete_lines(&mut self, n: usize) {
self.delete_lines_with(n, &Cell::blank());
}
pub fn delete_lines_with(&mut self, n: usize, blank: &Cell) {
self.check_invariants();
self.wrap_pending = false;
let top = self.cursor_row;
let bot = self.scroll_region_bottom;
if top > bot {
return;
}
let range = bot - top + 1;
let n = n.min(range);
if n == 0 {
return;
}
if n >= range {
for row in top..=bot {
self.reset_row_to_blank_with(row, blank);
self.mark_dirty(row);
}
return;
}
let end = bot - n;
for row in top..=end {
let src = (row + n).min(bot);
for c in 0..self.cols {
self.cells[row][c] = self.cells[src][c].clone();
}
}
for row in (end + 1)..=bot {
self.reset_row_to_blank_with(row, blank);
}
for row in top..=bot {
self.mark_dirty(row);
}
}
pub fn save_cursor(&mut self) {
self.saved_col = self.cursor_col;
self.saved_row = self.cursor_row;
self.saved_wrap_pending = self.wrap_pending;
}
pub fn restore_cursor(&mut self) {
self.check_invariants();
self.cursor_col = self.saved_col.min(self.cols.saturating_sub(1));
self.cursor_row = self.saved_row.min(self.rows.saturating_sub(1));
self.wrap_pending = self.saved_wrap_pending;
}
pub fn cursor_up(&mut self, n: usize) {
self.check_invariants();
self.wrap_pending = false;
let old_row = self.cursor_row;
self.cursor_row = self
.cursor_row
.saturating_sub(n)
.max(self.scroll_region_top);
if self.cursor_row != old_row {
self.mark_dirty(old_row);
self.mark_dirty(self.cursor_row);
}
}
pub fn cursor_down(&mut self, n: usize) {
self.check_invariants();
self.wrap_pending = false;
self.cursor_row = (self.cursor_row + n).min(self.scroll_region_bottom);
}
pub fn cursor_next_line(&mut self, n: usize) {
self.cursor_down(n);
self.cursor_col = 0;
}
pub fn cursor_prev_line(&mut self, n: usize) {
self.cursor_up(n);
self.cursor_col = 0;
}
pub fn cursor_forward(&mut self, n: usize) {
self.check_invariants();
self.wrap_pending = false;
self.cursor_col = (self.cursor_col + n).min(self.cols.saturating_sub(1));
}
pub fn cursor_backward(&mut self, n: usize) {
self.check_invariants();
self.wrap_pending = false;
self.cursor_col = self.cursor_col.saturating_sub(n);
}
pub fn cursor_horizontal_absolute(&mut self, col: usize) {
self.check_invariants();
self.wrap_pending = false;
self.cursor_col = col.min(self.cols.saturating_sub(1));
}
pub fn cursor_vertical_absolute(&mut self, row: usize) {
self.check_invariants();
self.wrap_pending = false;
let old_row = self.cursor_row;
self.cursor_row = row.min(self.rows.saturating_sub(1));
if self.cursor_row != old_row {
self.mark_dirty(old_row);
self.mark_dirty(self.cursor_row);
}
}
pub fn cursor_position(&mut self, row: usize, col: usize) {
self.check_invariants();
self.wrap_pending = false;
let old_row = self.cursor_row;
self.cursor_row = row.min(self.rows.saturating_sub(1));
self.cursor_col = col.min(self.cols.saturating_sub(1));
if self.cursor_row != old_row {
self.mark_dirty(old_row);
self.mark_dirty(self.cursor_row);
}
}
pub fn insert_chars(&mut self, n: usize) {
self.insert_chars_with(n, &Cell::blank());
}
pub fn insert_chars_with(&mut self, n: usize, blank: &Cell) {
self.check_invariants();
self.wrap_pending = false;
if self.cursor_row >= self.rows || self.cursor_col >= self.cols {
return;
}
let row = self.cursor_row;
let col = self.cursor_col;
let remaining = self.cols - col;
let n = n.min(remaining);
if n == 0 {
return;
}
if n >= remaining {
self.erase_row_range_with(row, col, self.cols, blank);
self.mark_dirty(row);
return;
}
for dst in (col + n..self.cols).rev() {
self.cells[row][dst] = self.cells[row][dst - n].clone();
}
for c in col..col + n {
self.cells[row][c] = blank_like(blank);
}
self.sanitize_row_clusters(row);
self.mark_dirty(row);
}
pub fn delete_chars(&mut self, n: usize) {
self.delete_chars_with(n, &Cell::blank());
}
pub fn delete_chars_with(&mut self, n: usize, blank: &Cell) {
self.check_invariants();
self.wrap_pending = false;
if self.cursor_row >= self.rows || self.cursor_col >= self.cols {
return;
}
let row = self.cursor_row;
let col = self.cursor_col;
let remaining = self.cols - col;
let n = n.min(remaining);
if n == 0 {
return;
}
if n >= remaining {
self.erase_row_range_with(row, col, self.cols, blank);
self.mark_dirty(row);
return;
}
for dst in col..self.cols - n {
self.cells[row][dst] = self.cells[row][dst + n].clone();
}
for c in self.cols - n..self.cols {
self.cells[row][c] = blank_like(blank);
}
self.sanitize_row_clusters(row);
self.mark_dirty(row);
}
pub fn erase_chars(&mut self, n: usize) {
self.erase_chars_with(n, &Cell::blank());
}
pub fn erase_chars_with(&mut self, n: usize, blank: &Cell) {
self.check_invariants();
self.wrap_pending = false;
if self.cursor_row >= self.rows || self.cursor_col >= self.cols {
return;
}
let end = self.cursor_col.saturating_add(n).min(self.cols);
self.erase_row_range_with(self.cursor_row, self.cursor_col, end, blank);
self.mark_dirty(self.cursor_row);
}
pub fn repeat_preceding_cell(&mut self, n: usize, mut scrollback: Option<&mut Scrollback>) {
self.check_invariants();
if n == 0 {
return;
}
let Some((row, col)) = self.previous_printable_cell() else {
return;
};
let cell = self.cells[row][col].clone();
for _ in 0..n {
self.put_cell(cell.clone(), scrollback.as_deref_mut());
}
}
fn sanitize_row_clusters(&mut self, row: usize) {
if row >= self.rows {
return;
}
let mut col = 0;
while col < self.cols {
if self.cells[row][col].continuation {
self.cells[row][col] = Cell::blank();
col += 1;
continue;
}
let width = self.cells[row][col]
.display_width()
.max(1)
.min(self.cols - col);
self.cells[row][col].width = width as u8;
let leader = self.cells[row][col].clone();
for offset in 1..width {
self.cells[row][col + offset] = Cell::continuation_of(&leader);
}
col += width;
}
}
pub fn clear_line_forward(&mut self) {
self.clear_line_forward_with(&Cell::blank());
}
pub fn clear_line_forward_with(&mut self, blank: &Cell) {
self.check_invariants();
self.wrap_pending = false;
if self.cursor_row < self.rows {
self.erase_row_range_with(self.cursor_row, self.cursor_col, self.cols, blank);
self.mark_dirty(self.cursor_row);
}
}
pub fn clear_line_from_start(&mut self) {
self.clear_line_from_start_with(&Cell::blank());
}
pub fn clear_line_from_start_with(&mut self, blank: &Cell) {
self.check_invariants();
self.wrap_pending = false;
if self.cursor_row < self.rows {
self.erase_row_range_with(self.cursor_row, 0, self.cursor_col.saturating_add(1), blank);
self.mark_dirty(self.cursor_row);
}
}
pub fn clear_line(&mut self) {
self.clear_line_with(&Cell::blank());
}
pub fn clear_line_with(&mut self, blank: &Cell) {
self.check_invariants();
self.wrap_pending = false;
if self.cursor_row < self.rows {
self.erase_row_range_with(self.cursor_row, 0, self.cols, blank);
self.mark_dirty(self.cursor_row);
}
}
pub fn clear_display_forward(&mut self) {
self.clear_display_forward_with(&Cell::blank());
}
pub fn clear_display_forward_with(&mut self, blank: &Cell) {
self.check_invariants();
self.clear_line_forward_with(blank);
for r in (self.cursor_row + 1)..self.rows {
self.erase_row_range_with(r, 0, self.cols, blank);
self.mark_dirty(r);
}
}
pub fn clear_display_backward(&mut self) {
self.clear_display_backward_with(&Cell::blank());
}
pub fn clear_display_backward_with(&mut self, blank: &Cell) {
self.check_invariants();
self.clear_line_from_start_with(blank);
for r in 0..self.cursor_row {
self.erase_row_range_with(r, 0, self.cols, blank);
self.mark_dirty(r);
}
}
pub fn clear_all(&mut self) {
self.clear_all_with(&Cell::blank());
}
pub fn clear_all_with(&mut self, blank: &Cell) {
self.check_invariants();
self.wrap_pending = false;
for row in 0..self.rows {
self.reset_row_to_blank_with(row, blank);
}
self.cursor_col = 0;
self.cursor_row = 0;
self.mark_all_dirty();
}
pub fn resize(&mut self, new_cols: usize, new_rows: usize) {
debug_assert!(
new_cols > 0 && new_rows > 0,
"resize dimensions must be positive"
);
self.check_invariants();
if new_cols == self.cols && new_rows == self.rows {
return;
}
let mut new = vec![vec![Cell::blank(); new_cols]; new_rows];
let cr = self.rows.min(new_rows);
let cc = self.cols.min(new_cols);
let os = self.rows - cr;
let ns = new_rows - cr;
for r in 0..cr {
let src = &self.cells[os + r];
let dst = &mut new[ns + r];
for (c, cell) in src.iter().enumerate().take(cc) {
dst[c] = cell.clone();
}
}
self.cells = new;
self.cols = new_cols;
self.rows = new_rows;
self.cursor_col = self.cursor_col.min(new_cols.saturating_sub(1));
self.cursor_row = self.cursor_row.min(new_rows.saturating_sub(1));
self.wrap_pending = false;
self.dirty_rows = vec![true; new_rows];
self.scroll_region_top = 0;
self.scroll_region_bottom = new_rows.saturating_sub(1);
self.check_invariants();
}
pub fn dirty_count(&self) -> usize {
self.dirty_rows.iter().filter(|&&d| d).count()
}
}
impl std::fmt::Display for Grid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (ri, row) in self.cells.iter().enumerate() {
for cell in row {
if !cell.continuation {
write!(f, "{}", cell.c)?;
if let Some(extra) = &cell.extra {
write!(f, "{extra}")?;
}
}
}
if ri + 1 < self.rows {
writeln!(f)?;
}
}
Ok(())
}
}
fn is_plain_space(cell: &Cell) -> bool {
!cell.continuation && cell.c == ' ' && cell.extra.is_none() && cell.display_width() == 1
}
fn blank_like(template: &Cell) -> Cell {
Cell::blank_with_attrs(template.fg, template.bg, template.attrs)
}
fn blank_recycled_row(row: Option<Vec<Cell>>, cols: usize, blank: &Cell) -> Vec<Cell> {
let mut row = row
.filter(|row| row.len() == cols)
.unwrap_or_else(|| vec![Cell::blank(); cols]);
row.fill(blank_like(blank));
row
}
fn cell_fragments(cell: &Cell, base_col: usize, cluster_end: usize) -> Vec<CellFragment> {
if cell.continuation {
return Vec::new();
}
let mut fragments = Vec::new();
let mut current = String::new();
let mut current_start = base_col;
let mut current_width = 0usize;
let mut col = base_col;
for ch in cell.text().chars() {
let width = char_display_width(ch) as usize;
if width == 0 {
if current.is_empty() {
current_start = col;
current_width = 1;
}
current.push(ch);
continue;
}
if !current.is_empty() {
push_cell_fragment(
&mut fragments,
std::mem::take(&mut current),
current_start,
current_width,
cluster_end,
);
col = current_start + current_width;
}
current_start = col;
current_width = width.max(1);
current.push(ch);
}
if !current.is_empty() {
push_cell_fragment(
&mut fragments,
current,
current_start,
current_width,
cluster_end,
);
}
fragments
}
fn push_cell_fragment(
fragments: &mut Vec<CellFragment>,
text: String,
start: usize,
width: usize,
cluster_end: usize,
) {
if start >= cluster_end || text.is_empty() {
return;
}
let end = (start + width.max(1)).min(cluster_end);
fragments.push(CellFragment { text, start, end });
}
#[cfg(test)]
mod tests {
use super::super::cell::ATTR_INVERSE;
use super::super::scrollback::Scrollback;
use super::*;
#[test]
fn test_newline_no_scrollback() {
let mut g = Grid::new(3, 3);
g.put_cell(Cell::new('1'), None);
g.newline(None);
assert_eq!(g.cursor_row, 1);
}
#[test]
fn test_scroll_up_pushes_to_scrollback() {
let mut g = Grid::new(3, 3);
let mut sb = Scrollback::new();
g.put_cell(Cell::new('1'), None);
g.newline(Some(&mut sb));
g.put_cell(Cell::new('2'), None);
g.newline(Some(&mut sb));
g.put_cell(Cell::new('3'), None);
g.newline(Some(&mut sb));
g.put_cell(Cell::new('4'), None);
assert_eq!(sb.len(), 1);
assert_eq!(g.cell(0, 0).unwrap().c, '2');
}
#[test]
fn multi_row_scroll_preserves_scrollback_order_with_recycling() {
let mut g = Grid::new(1, 4);
let mut sb = Scrollback::new_scrollback(1);
for row in 0..4 {
g.cells[row][0] = Cell::new((b'A' + row as u8) as char);
}
g.scroll_up(2, Some(&mut sb));
assert_eq!(sb.len(), 1);
assert_eq!(sb.row_text(0, 1), "B");
assert_eq!(g.row_text(0), "C");
assert_eq!(g.row_text(1), "D");
assert_eq!(g.row_text(2), " ");
assert_eq!(g.row_text(3), " ");
}
#[test]
fn full_region_scroll_blanks_region_and_keeps_scrollback_order() {
let mut g = Grid::new(1, 3);
let mut sb = Scrollback::new_scrollback(3);
for row in 0..3 {
g.cells[row][0] = Cell::new((b'A' + row as u8) as char);
}
g.scroll_up(3, Some(&mut sb));
assert_eq!(sb.len(), 3);
assert_eq!(sb.row_text(0, 1), "A");
assert_eq!(sb.row_text(1, 1), "B");
assert_eq!(sb.row_text(2, 1), "C");
assert_eq!(g.row_text(0), " ");
assert_eq!(g.row_text(1), " ");
assert_eq!(g.row_text(2), " ");
}
#[test]
fn test_wide_character_occupies_two_cells() {
let mut g = Grid::new(4, 2);
g.put_cell(Cell::new('界'), None);
g.put_cell(Cell::new('x'), None);
assert_eq!(g.row_text(0), "界x ");
assert_eq!(g.cell(0, 0).unwrap().width, 2);
assert!(g.cell(0, 1).unwrap().continuation);
assert_eq!(g.cursor_col, 3);
}
#[test]
fn test_combining_mark_attaches_to_previous_cell() {
let mut g = Grid::new(4, 2);
g.put_cell(Cell::new('e'), None);
g.put_cell(Cell::new('\u{0301}'), None);
assert_eq!(g.row_text(0), "e\u{0301} ");
assert_eq!(g.cursor_col, 1);
}
#[test]
fn test_zwj_emoji_cluster_occupies_two_cells() {
let mut g = Grid::new(4, 2);
let family = "👨\u{200d}👩\u{200d}👧\u{200d}👦";
for c in family.chars() {
g.put_cell(Cell::new(c), None);
}
assert_eq!(g.row_text(0), format!("{family} "));
assert_eq!(g.cell(0, 0).unwrap().width, 2);
assert!(g.cell(0, 1).unwrap().continuation);
assert_eq!(g.cursor_col, 2);
}
#[test]
fn test_regional_indicator_flag_cluster_occupies_two_cells() {
let mut g = Grid::new(4, 2);
let flag = "🇺🇸";
for c in flag.chars() {
g.put_cell(Cell::new(c), None);
}
assert_eq!(g.row_text(0), format!("{flag} "));
assert_eq!(g.cell(0, 0).unwrap().width, 2);
assert!(g.cell(0, 1).unwrap().continuation);
assert_eq!(g.cursor_col, 2);
}
#[test]
fn test_emoji_modifier_cluster_matches_shell_columns() {
let mut g = Grid::new(8, 2);
let emoji = "👍🏽";
for c in emoji.chars() {
g.put_cell(Cell::new(c), None);
}
assert_eq!(g.row_text(0), format!("{emoji} "));
assert_eq!(g.cell(0, 0).unwrap().width, 4);
assert!(g.cell(0, 1).unwrap().continuation);
assert!(g.cell(0, 2).unwrap().continuation);
assert!(g.cell(0, 3).unwrap().continuation);
assert!(!g.cell(0, 4).unwrap().continuation);
assert_eq!(g.cursor_col, 4);
}
#[test]
fn space_overwrite_splits_emoji_modifier_cluster_on_legacy_boundary() {
let mut g = Grid::new(8, 2);
for c in "👍🏽".chars() {
g.put_cell(Cell::new(c), None);
}
g.cursor_position(0, 2);
g.put_cell(Cell::new(' '), None);
g.put_cell(Cell::new(' '), None);
assert_eq!(g.row_text(0), "👍 ");
assert_eq!(g.cell(0, 0).unwrap().text(), "👍");
assert_eq!(g.cell(0, 0).unwrap().width, 2);
assert!(g.cell(0, 1).unwrap().continuation);
assert!(!g.cell(0, 2).unwrap().continuation);
assert!(!g.cell(0, 3).unwrap().continuation);
}
#[test]
fn mixed_emoji_and_cjk_advance_matches_terminal_columns() {
let mut g = Grid::new(32, 2);
let text = "unicode: café 👍🏽 你好";
for c in text.chars() {
g.put_cell(Cell::new(c), None);
}
assert_eq!(g.row_text(0), format!("{text} "));
assert_eq!(g.cursor_col, 23);
assert!(g.cell(0, 15).unwrap().continuation);
assert!(g.cell(0, 16).unwrap().continuation);
assert!(g.cell(0, 17).unwrap().continuation);
assert_eq!(g.cell(0, 19).unwrap().c, '你');
assert!(g.cell(0, 20).unwrap().continuation);
assert_eq!(g.cell(0, 21).unwrap().c, '好');
assert!(g.cell(0, 22).unwrap().continuation);
assert_eq!(g.cell(0, 23).unwrap().c, ' ');
}
#[test]
fn overwriting_wide_cluster_continuation_clears_whole_cluster() {
let mut g = Grid::new(8, 2);
for c in "👍🏽".chars() {
g.put_cell(Cell::new(c), None);
}
g.cursor_position(0, 2);
g.put_cell(Cell::new('x'), None);
assert_eq!(g.row_text(0), " x ");
assert_eq!(g.cell(0, 0).unwrap().c, ' ');
assert!(!g.cell(0, 1).unwrap().continuation);
}
#[test]
fn ascii_run_overwrites_complete_wide_cluster_without_orphans() {
let mut g = Grid::new(8, 2);
for c in "a好b".chars() {
g.put_cell(Cell::new(c), None);
}
g.cursor_position(0, 1);
g.put_ascii_bytes(b"XY", 0, 0, 0, 0, None);
assert_eq!(g.row_text(0), "aXYb ");
assert!(!g.cell(0, 1).unwrap().continuation);
assert!(!g.cell(0, 2).unwrap().continuation);
assert_eq!(g.cell(0, 3).unwrap().c, 'b');
}
#[test]
fn ascii_run_falls_back_when_right_boundary_splits_wide_cluster() {
let mut g = Grid::new(8, 2);
for c in "a好b".chars() {
g.put_cell(Cell::new(c), None);
}
g.cursor_position(0, 1);
g.put_ascii_bytes(b"X", 0, 0, 0, 0, None);
assert_eq!(g.row_text(0), "aX b ");
assert!(!g.cell(0, 1).unwrap().continuation);
assert!(!g.cell(0, 2).unwrap().continuation);
assert_eq!(g.cell(0, 3).unwrap().c, 'b');
}
#[test]
fn ascii_run_falls_back_when_left_boundary_starts_on_continuation() {
let mut g = Grid::new(8, 2);
for c in "a好b".chars() {
g.put_cell(Cell::new(c), None);
}
g.cursor_position(0, 2);
g.put_ascii_bytes(b"Y", 0, 0, 0, 0, None);
assert_eq!(g.row_text(0), "a Yb ");
assert_eq!(g.cell(0, 1).unwrap().c, ' ');
assert!(!g.cell(0, 1).unwrap().continuation);
assert_eq!(g.cell(0, 2).unwrap().c, 'Y');
assert!(!g.cell(0, 2).unwrap().continuation);
}
#[test]
fn clear_line_forward_from_wide_continuation_erases_whole_cluster() {
let mut g = Grid::new(8, 2);
g.put_cell(Cell::new('a'), None);
g.put_cell(Cell::new('好'), None);
g.put_cell(Cell::new('b'), None);
g.cursor_position(0, 2);
g.clear_line_forward();
assert_eq!(g.row_text(0), "a ");
assert_eq!(g.cell(0, 1).unwrap().c, ' ');
assert!(!g.cell(0, 2).unwrap().continuation);
}
#[test]
fn clear_line_from_start_at_wide_leader_erases_whole_cluster() {
let mut g = Grid::new(8, 2);
g.put_cell(Cell::new('a'), None);
g.put_cell(Cell::new('好'), None);
g.put_cell(Cell::new('b'), None);
g.cursor_position(0, 1);
g.clear_line_from_start();
assert_eq!(g.row_text(0), " b ");
assert_eq!(g.cell(0, 1).unwrap().c, ' ');
assert!(!g.cell(0, 2).unwrap().continuation);
}
#[test]
fn test_devanagari_virama_cluster_stays_in_one_cell() {
let mut g = Grid::new(6, 2);
let cluster = "क\u{094d}ष";
for c in cluster.chars() {
g.put_cell(Cell::new(c), None);
}
g.put_cell(Cell::new('x'), None);
assert_eq!(g.row_text(0), format!("{cluster}x "));
assert_eq!(g.cell(0, 0).unwrap().width, 1);
assert_eq!(g.cursor_col, 2);
}
#[test]
fn test_hangul_jamo_cluster_stays_in_one_cell() {
let mut g = Grid::new(6, 2);
let cluster = "한";
for c in cluster.chars() {
g.put_cell(Cell::new(c), None);
}
g.put_cell(Cell::new('x'), None);
assert_eq!(g.row_text(0), format!("{cluster}x "));
assert_eq!(g.cell(0, 0).unwrap().width, 2);
assert!(g.cell(0, 1).unwrap().continuation);
assert_eq!(g.cursor_col, 3);
}
#[test]
fn test_scroll_region_bounds_scrolling() {
let mut g = Grid::new(5, 5);
g.set_scroll_region(2, 4); for r in 0..5 {
for c in 0..5 {
g.cells[r][c] = Cell::new((b'0' + r as u8) as char);
}
}
let mut sb = Scrollback::new();
g.cursor_position(1, 0); g.cursor_position(3, 0); g.newline(Some(&mut sb)); assert_eq!(g.cell(0, 0).unwrap().c, '0', "row 0 unchanged");
assert_eq!(g.cell(4, 0).unwrap().c, '4', "row 4 unchanged");
}
#[test]
fn test_cursor_save_restore() {
let mut g = Grid::new(10, 10);
g.cursor_position(5, 7);
g.save_cursor();
g.cursor_position(0, 0);
g.restore_cursor();
assert_eq!(g.cursor_row, 5);
assert_eq!(g.cursor_col, 7);
}
#[test]
fn test_insert_lines() {
let mut g = Grid::new(3, 5);
for r in 0..5 {
for c in 0..3 {
g.cells[r][c] = Cell::new((b'A' + r as u8) as char);
}
}
g.cursor_position(1, 0); g.insert_lines(2);
assert_eq!(g.cell(0, 0).unwrap().c, 'A');
assert_eq!(g.cell(1, 0).unwrap().c, ' '); assert_eq!(g.cell(2, 0).unwrap().c, ' '); assert_eq!(g.cell(3, 0).unwrap().c, 'B'); assert_eq!(g.cell(4, 0).unwrap().c, 'C'); }
#[test]
fn test_delete_lines() {
let mut g = Grid::new(3, 5);
for r in 0..5 {
for c in 0..3 {
g.cells[r][c] = Cell::new((b'A' + r as u8) as char);
}
}
g.cursor_position(1, 0);
g.delete_lines(2);
assert_eq!(g.cell(0, 0).unwrap().c, 'A');
assert_eq!(g.cell(1, 0).unwrap().c, 'D'); assert_eq!(g.cell(2, 0).unwrap().c, 'E'); assert_eq!(g.cell(3, 0).unwrap().c, ' '); assert_eq!(g.cell(4, 0).unwrap().c, ' '); }
#[test]
fn test_cr_moves_col_to_0() {
let mut g = Grid::new(5, 3);
g.put_cell(Cell::new('a'), None);
g.put_cell(Cell::new('b'), None);
assert_eq!(g.cursor_col, 2);
g.carriage_return();
assert_eq!(g.cursor_col, 0);
}
#[test]
fn autowrap_defers_until_next_printable() {
let mut g = Grid::new(5, 3);
g.put_ascii_bytes(b"abcde", 0, 0, 0, 0, None);
assert_eq!(g.row_text(0), "abcde");
assert_eq!(g.cursor_row, 0);
assert_eq!(g.cursor_col, 4);
assert!(g.wrap_pending);
g.put_cell(Cell::new('X'), None);
assert_eq!(g.row_text(0), "abcde");
assert_eq!(g.row_text(1), "X ");
assert_eq!(g.cursor_row, 1);
assert_eq!(g.cursor_col, 1);
assert!(!g.wrap_pending);
}
#[test]
fn pending_wrap_does_not_split_zwj_cluster() {
let mut g = Grid::new(2, 2);
let cluster = "👨\u{200d}👩";
for c in cluster.chars() {
g.put_cell(Cell::new(c), None);
}
assert_eq!(g.row_text(0), cluster);
assert_eq!(g.row_text(1), " ");
assert!(g.wrap_pending);
g.put_cell(Cell::new('X'), None);
assert_eq!(g.row_text(0), cluster);
assert_eq!(g.row_text(1), "X ");
}
#[test]
fn carriage_return_clears_pending_wrap_on_same_row() {
let mut g = Grid::new(5, 3);
g.put_ascii_bytes(b"abcde", 0, 0, 0, 0, None);
g.carriage_return();
g.put_cell(Cell::new('X'), None);
assert_eq!(g.row_text(0), "Xbcde");
assert_eq!(g.cursor_row, 0);
assert_eq!(g.cursor_col, 1);
assert!(!g.wrap_pending);
}
#[test]
fn zsh_transient_prompt_clear_does_not_leave_inverse_prompt() {
let mut g = Grid::new(5, 3);
g.put_cell(Cell::with_attrs('%', 0, 0, ATTR_INVERSE), None);
g.put_ascii_bytes(b" ", 0, 0, 0, 0, None);
g.carriage_return();
g.put_cell(Cell::new(' '), None);
g.carriage_return();
g.clear_display_forward();
g.put_ascii_bytes(b"sh% ", 0, 0, 0, 0, None);
assert_eq!(g.row_text(0), "sh% ");
assert_eq!(g.row_text(1), " ");
assert_eq!(g.cell(0, 0).unwrap().attrs, 0);
}
#[test]
fn test_cursor_forward_backward() {
let mut g = Grid::new(10, 3);
g.cursor_forward(3);
assert_eq!(g.cursor_col, 3);
g.cursor_backward(2);
assert_eq!(g.cursor_col, 1);
}
#[test]
fn test_cursor_up_down() {
let mut g = Grid::new(5, 5);
g.newline(None);
g.newline(None);
g.cursor_up(1);
assert_eq!(g.cursor_row, 1);
g.cursor_down(2);
assert_eq!(g.cursor_row, 3);
}
}