785 lines
29 KiB
Rust
785 lines
29 KiB
Rust
use crate::cell::{Cell, Style};
|
|
use crate::grid::Grid;
|
|
use std::collections::VecDeque;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum CursorShape {
|
|
Block,
|
|
Bar,
|
|
Underline,
|
|
}
|
|
|
|
impl Default for CursorShape {
|
|
fn default() -> Self {
|
|
CursorShape::Block
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Cursor {
|
|
pub x: usize,
|
|
pub y: usize,
|
|
pub style: Style,
|
|
pub pending_wrap: bool,
|
|
pub visible: bool,
|
|
pub shape: CursorShape,
|
|
}
|
|
|
|
impl Default for Cursor {
|
|
fn default() -> Self {
|
|
Self {
|
|
x: 0,
|
|
y: 0,
|
|
style: Style::default(),
|
|
pending_wrap: false,
|
|
visible: true,
|
|
shape: CursorShape::Block,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct SavedCursor {
|
|
x: usize,
|
|
y: usize,
|
|
style: Style,
|
|
pending_wrap: bool,
|
|
}
|
|
|
|
/// A single terminal screen (primary or alternate).
|
|
pub struct Screen {
|
|
pub grid: Grid,
|
|
pub cursor: Cursor,
|
|
saved_cursor: Option<SavedCursor>,
|
|
|
|
// Scroll region (inclusive top, exclusive bottom)
|
|
pub scroll_top: usize,
|
|
pub scroll_bottom: usize,
|
|
|
|
// Scrollback (primary screen only)
|
|
scrollback: VecDeque<Vec<Cell>>,
|
|
pub scrollback_wrapped: VecDeque<bool>,
|
|
pub max_scrollback: usize,
|
|
|
|
// Tab stops
|
|
pub tabstops: Vec<bool>,
|
|
|
|
// Highest grid row that has been written to (0-based, inclusive).
|
|
// Tracks content extent independent of where the cursor happens to be.
|
|
pub high_water_row: usize,
|
|
// Rows recently pulled from scrollback during grows. If a following shrink
|
|
// would normally trim only bottom rows (copy_start == 0), we first push
|
|
// this many rows back into scrollback from the top to make grow->shrink
|
|
// round-trips stable for TUI layouts on the primary screen.
|
|
bottom_trimmed_rows: usize,
|
|
}
|
|
|
|
impl Screen {
|
|
pub fn new(cols: usize, rows: usize, with_scrollback: bool) -> Self {
|
|
let mut tabstops = vec![false; cols];
|
|
for i in (0..cols).step_by(8) {
|
|
tabstops[i] = true;
|
|
}
|
|
|
|
Self {
|
|
grid: Grid::new(cols, rows),
|
|
cursor: Cursor::default(),
|
|
saved_cursor: None,
|
|
scroll_top: 0,
|
|
scroll_bottom: rows,
|
|
scrollback: VecDeque::new(),
|
|
scrollback_wrapped: VecDeque::new(),
|
|
max_scrollback: if with_scrollback { 10000 } else { 0 },
|
|
tabstops,
|
|
high_water_row: 0,
|
|
bottom_trimmed_rows: 0,
|
|
}
|
|
}
|
|
|
|
pub fn cols(&self) -> usize {
|
|
self.grid.cols
|
|
}
|
|
|
|
pub fn rows(&self) -> usize {
|
|
self.grid.rows
|
|
}
|
|
|
|
/// Move cursor, clamping to bounds. Clears pending_wrap.
|
|
pub fn move_cursor_to(&mut self, x: usize, y: usize) {
|
|
self.cursor.x = x.min(self.cols() - 1);
|
|
self.cursor.y = y.min(self.rows() - 1);
|
|
self.cursor.pending_wrap = false;
|
|
}
|
|
|
|
/// Write a character at cursor position, advancing cursor.
|
|
pub fn write_char(&mut self, c: char) {
|
|
if self.cursor.pending_wrap {
|
|
// Mark the row we're leaving as soft-wrapped (content continues on next row)
|
|
self.grid.line_wrapped[self.cursor.y] = true;
|
|
self.do_linefeed();
|
|
self.cursor.x = 0;
|
|
self.cursor.pending_wrap = false;
|
|
}
|
|
|
|
let col = self.cursor.x;
|
|
let row = self.cursor.y;
|
|
|
|
if col < self.cols() && row < self.rows() {
|
|
let cell = self.grid.cell_mut(col, row);
|
|
cell.codepoint = c;
|
|
cell.style = self.cursor.style;
|
|
cell.flags = crate::cell::CellFlags::default();
|
|
if row > self.high_water_row {
|
|
self.high_water_row = row;
|
|
}
|
|
}
|
|
|
|
if self.cursor.x >= self.cols() - 1 {
|
|
// At right margin — set pending wrap
|
|
self.cursor.pending_wrap = true;
|
|
} else {
|
|
self.cursor.x += 1;
|
|
}
|
|
}
|
|
|
|
/// Line feed: move cursor down, scrolling if needed.
|
|
pub fn do_linefeed(&mut self) {
|
|
if self.cursor.y + 1 >= self.scroll_bottom {
|
|
self.scroll_up(1);
|
|
// Scrolling means content reached the bottom of the scroll region
|
|
let bottom = self.scroll_bottom.saturating_sub(1);
|
|
if bottom > self.high_water_row {
|
|
self.high_water_row = bottom;
|
|
}
|
|
} else {
|
|
self.cursor.y += 1;
|
|
if self.cursor.y > self.high_water_row {
|
|
self.high_water_row = self.cursor.y;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Carriage return: move cursor to column 0.
|
|
pub fn do_carriage_return(&mut self) {
|
|
self.cursor.x = 0;
|
|
self.cursor.pending_wrap = false;
|
|
}
|
|
|
|
/// Backspace: move cursor left by 1.
|
|
pub fn do_backspace(&mut self) {
|
|
if self.cursor.x > 0 {
|
|
self.cursor.x -= 1;
|
|
self.cursor.pending_wrap = false;
|
|
}
|
|
}
|
|
|
|
/// Horizontal tab: advance to next tab stop.
|
|
pub fn do_tab(&mut self) {
|
|
let x = self.cursor.x + 1;
|
|
for i in x..self.cols() {
|
|
if self.tabstops[i] {
|
|
self.cursor.x = i;
|
|
self.cursor.pending_wrap = false;
|
|
return;
|
|
}
|
|
}
|
|
self.cursor.x = self.cols() - 1;
|
|
self.cursor.pending_wrap = false;
|
|
}
|
|
|
|
/// Scroll up within scroll region. Top line goes to scrollback.
|
|
pub fn scroll_up(&mut self, count: usize) {
|
|
// Save scrolled-off lines to scrollback
|
|
if self.max_scrollback > 0 && self.scroll_top == 0 {
|
|
for i in 0..count.min(self.scroll_bottom) {
|
|
let row = self.grid.row_slice(i).to_vec();
|
|
let wrapped = self.grid.line_wrapped[i];
|
|
self.scrollback.push_back(row);
|
|
self.scrollback_wrapped.push_back(wrapped);
|
|
if self.scrollback.len() > self.max_scrollback {
|
|
self.scrollback.pop_front();
|
|
self.scrollback_wrapped.pop_front();
|
|
}
|
|
}
|
|
}
|
|
self.grid.scroll_up(
|
|
self.scroll_top,
|
|
self.scroll_bottom,
|
|
count,
|
|
self.cursor.style,
|
|
);
|
|
}
|
|
|
|
/// Scroll down within scroll region.
|
|
pub fn scroll_down(&mut self, count: usize) {
|
|
self.grid.scroll_down(
|
|
self.scroll_top,
|
|
self.scroll_bottom,
|
|
count,
|
|
self.cursor.style,
|
|
);
|
|
}
|
|
|
|
/// Erase in display
|
|
pub fn erase_display(&mut self, mode: u16) {
|
|
let style = self.cursor.style;
|
|
match mode {
|
|
0 => {
|
|
// Below (from cursor to end)
|
|
self.erase_line(0); // cursor to end of line
|
|
self.grid.line_wrapped[self.cursor.y] = false;
|
|
for row in self.cursor.y + 1..self.rows() {
|
|
self.grid.clear_row(row, style);
|
|
self.grid.line_wrapped[row] = false;
|
|
}
|
|
}
|
|
1 => {
|
|
// Above (from start to cursor)
|
|
for row in 0..self.cursor.y {
|
|
self.grid.clear_row(row, style);
|
|
self.grid.line_wrapped[row] = false;
|
|
}
|
|
self.erase_line(1); // start of line to cursor
|
|
}
|
|
2 => {
|
|
// Entire display
|
|
for row in 0..self.rows() {
|
|
self.grid.clear_row(row, style);
|
|
self.grid.line_wrapped[row] = false;
|
|
}
|
|
}
|
|
3 => {
|
|
// Entire display + scrollback
|
|
self.scrollback.clear();
|
|
self.scrollback_wrapped.clear();
|
|
for row in 0..self.rows() {
|
|
self.grid.clear_row(row, style);
|
|
self.grid.line_wrapped[row] = false;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Erase in line
|
|
pub fn erase_line(&mut self, mode: u16) {
|
|
let row = self.cursor.y;
|
|
let style = self.cursor.style;
|
|
match mode {
|
|
0 => {
|
|
// From cursor to end — row can no longer wrap
|
|
for col in self.cursor.x..self.cols() {
|
|
self.grid.cell_mut(col, row).clear_with_style(style);
|
|
}
|
|
self.grid.line_wrapped[row] = false;
|
|
}
|
|
1 => {
|
|
// From start to cursor
|
|
for col in 0..=self.cursor.x.min(self.cols() - 1) {
|
|
self.grid.cell_mut(col, row).clear_with_style(style);
|
|
}
|
|
}
|
|
2 => {
|
|
// Entire line
|
|
self.grid.clear_row(row, style);
|
|
self.grid.line_wrapped[row] = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Erase N characters from cursor
|
|
pub fn erase_chars(&mut self, count: usize) {
|
|
let row = self.cursor.y;
|
|
let style = self.cursor.style;
|
|
let end = (self.cursor.x + count).min(self.cols());
|
|
for col in self.cursor.x..end {
|
|
self.grid.cell_mut(col, row).clear_with_style(style);
|
|
}
|
|
}
|
|
|
|
/// Insert blank lines at cursor, pushing down
|
|
pub fn insert_lines(&mut self, count: usize) {
|
|
if self.cursor.y >= self.scroll_top && self.cursor.y < self.scroll_bottom {
|
|
self.grid
|
|
.scroll_down(self.cursor.y, self.scroll_bottom, count, self.cursor.style);
|
|
}
|
|
}
|
|
|
|
/// Delete lines at cursor, pulling up
|
|
pub fn delete_lines(&mut self, count: usize) {
|
|
if self.cursor.y >= self.scroll_top && self.cursor.y < self.scroll_bottom {
|
|
self.grid
|
|
.scroll_up(self.cursor.y, self.scroll_bottom, count, self.cursor.style);
|
|
}
|
|
}
|
|
|
|
/// Insert blank chars at cursor
|
|
pub fn insert_blanks(&mut self, count: usize) {
|
|
self.grid
|
|
.insert_blanks(self.cursor.x, self.cursor.y, count, self.cursor.style);
|
|
}
|
|
|
|
/// Delete chars at cursor
|
|
pub fn delete_chars(&mut self, count: usize) {
|
|
self.grid
|
|
.delete_chars(self.cursor.x, self.cursor.y, count, self.cursor.style);
|
|
}
|
|
|
|
/// Save cursor position
|
|
pub fn save_cursor(&mut self) {
|
|
self.saved_cursor = Some(SavedCursor {
|
|
x: self.cursor.x,
|
|
y: self.cursor.y,
|
|
style: self.cursor.style,
|
|
pending_wrap: self.cursor.pending_wrap,
|
|
});
|
|
}
|
|
|
|
/// Restore cursor position
|
|
pub fn restore_cursor(&mut self) {
|
|
if let Some(saved) = &self.saved_cursor {
|
|
self.cursor.x = saved.x.min(self.cols() - 1);
|
|
self.cursor.y = saved.y.min(self.rows() - 1);
|
|
self.cursor.style = saved.style;
|
|
self.cursor.pending_wrap = saved.pending_wrap;
|
|
}
|
|
}
|
|
|
|
/// Set scroll region (1-based, inclusive)
|
|
pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {
|
|
let top = top.saturating_sub(1); // Convert to 0-based
|
|
let bottom = if bottom == 0 {
|
|
self.rows()
|
|
} else {
|
|
bottom.min(self.rows())
|
|
};
|
|
if top < bottom {
|
|
self.scroll_top = top;
|
|
self.scroll_bottom = bottom;
|
|
}
|
|
}
|
|
|
|
/// Resize the screen
|
|
pub fn resize(&mut self, cols: usize, rows: usize) {
|
|
let cols = cols.max(1);
|
|
let rows = rows.max(1);
|
|
let old_cols = self.cols();
|
|
let old_rows = self.rows();
|
|
|
|
if cols == old_cols && rows == old_rows {
|
|
return;
|
|
}
|
|
|
|
// Alternate screen (max_scrollback == 0): the TUI app will fully redraw
|
|
// after receiving SIGWINCH, so just create a fresh grid and clamp the
|
|
// cursor. This matches Rio (reflow: false for alt screen) and WezTerm
|
|
// (allow_scrollback == false path).
|
|
if self.max_scrollback == 0 {
|
|
self.grid = Grid::new(cols, rows);
|
|
self.cursor.x = self.cursor.x.min(cols - 1);
|
|
self.cursor.y = self.cursor.y.min(rows - 1);
|
|
self.cursor.pending_wrap = false;
|
|
if let Some(saved) = &mut self.saved_cursor {
|
|
saved.x = saved.x.min(cols - 1);
|
|
saved.y = saved.y.min(rows - 1);
|
|
saved.pending_wrap = false;
|
|
}
|
|
self.scroll_top = 0;
|
|
self.scroll_bottom = rows;
|
|
self.high_water_row = 0;
|
|
self.bottom_trimmed_rows = 0;
|
|
self.tabstops = vec![false; cols];
|
|
for i in (0..cols).step_by(8) {
|
|
self.tabstops[i] = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Primary screen.
|
|
if cols != old_cols {
|
|
// Width changed — reflow content at the new width.
|
|
self.reflow_resize(cols, rows);
|
|
} else {
|
|
// Only height changed — manage rows without reflowing.
|
|
self.rows_only_resize(rows);
|
|
}
|
|
}
|
|
|
|
/// Resize when only the row count changed (cols stay the same).
|
|
fn rows_only_resize(&mut self, rows: usize) {
|
|
let old_rows = self.rows();
|
|
let cols = self.cols();
|
|
let was_full = self.used_rows() >= old_rows;
|
|
let has_content_near_bottom = self.last_non_empty_row() >= old_rows.saturating_sub(2);
|
|
|
|
let old_grid_rows: Vec<Vec<Cell>> = (0..old_rows)
|
|
.map(|row| self.grid.row_slice(row).to_vec())
|
|
.collect();
|
|
let old_wrapped: Vec<bool> = self.grid.line_wrapped.clone();
|
|
let mut new_grid = Grid::new(cols, rows);
|
|
|
|
let copy_row = |src: &[Cell], dst_row: usize, grid: &mut Grid| {
|
|
for col in 0..cols.min(src.len()) {
|
|
*grid.cell_mut(col, dst_row) = src[col];
|
|
}
|
|
};
|
|
|
|
match rows.cmp(&old_rows) {
|
|
std::cmp::Ordering::Greater => {
|
|
let has_custom_scroll_region =
|
|
self.scroll_top != 0 || self.scroll_bottom != old_rows;
|
|
let pull_count = if was_full {
|
|
self.scrollback.len().min(rows - old_rows)
|
|
} else {
|
|
0
|
|
};
|
|
|
|
if pull_count > 0 {
|
|
let sb_start = self.scrollback.len() - pull_count;
|
|
let pulled_rows: Vec<Vec<Cell>> = self.scrollback.drain(sb_start..).collect();
|
|
let pulled_wrapped: Vec<bool> =
|
|
self.scrollback_wrapped.drain(sb_start..).collect();
|
|
for (i, row) in pulled_rows.iter().enumerate() {
|
|
copy_row(row, i, &mut new_grid);
|
|
new_grid.line_wrapped[i] = pulled_wrapped.get(i).copied().unwrap_or(false);
|
|
}
|
|
for (src_row, row) in old_grid_rows.iter().enumerate() {
|
|
copy_row(row, src_row + pull_count, &mut new_grid);
|
|
new_grid.line_wrapped[src_row + pull_count] = old_wrapped[src_row];
|
|
}
|
|
self.cursor.y += pull_count;
|
|
self.high_water_row += pull_count;
|
|
self.bottom_trimmed_rows = self.bottom_trimmed_rows.saturating_add(pull_count);
|
|
if let Some(saved) = &mut self.saved_cursor {
|
|
saved.y += pull_count;
|
|
}
|
|
} else if has_custom_scroll_region && has_content_near_bottom {
|
|
let shift = rows - old_rows;
|
|
for (src_row, row) in old_grid_rows.iter().enumerate() {
|
|
copy_row(row, src_row + shift, &mut new_grid);
|
|
new_grid.line_wrapped[src_row + shift] = old_wrapped[src_row];
|
|
}
|
|
self.cursor.y += shift;
|
|
self.high_water_row += shift;
|
|
if let Some(saved) = &mut self.saved_cursor {
|
|
saved.y += shift;
|
|
}
|
|
} else {
|
|
for (src_row, row) in old_grid_rows.iter().enumerate() {
|
|
copy_row(row, src_row, &mut new_grid);
|
|
new_grid.line_wrapped[src_row] = old_wrapped[src_row];
|
|
}
|
|
if was_full {
|
|
self.high_water_row = rows - 1;
|
|
}
|
|
}
|
|
}
|
|
std::cmp::Ordering::Less => {
|
|
let required_scrolling = (self.cursor.y + 1).saturating_sub(rows);
|
|
let lines_removed = old_rows - rows;
|
|
let mut copy_start = required_scrolling.min(lines_removed);
|
|
|
|
let has_custom_scroll_region =
|
|
self.scroll_top != 0 || self.scroll_bottom != old_rows;
|
|
let has_text_below_cursor = self.last_non_empty_row() > self.cursor.y;
|
|
|
|
if (has_custom_scroll_region && has_content_near_bottom) || has_text_below_cursor {
|
|
copy_start = lines_removed;
|
|
} else if copy_start == 0 && self.bottom_trimmed_rows > 0 {
|
|
copy_start = self.bottom_trimmed_rows.min(lines_removed);
|
|
}
|
|
|
|
if copy_start > 0 {
|
|
for i in 0..copy_start {
|
|
self.scrollback.push_back(old_grid_rows[i].clone());
|
|
self.scrollback_wrapped.push_back(old_wrapped[i]);
|
|
}
|
|
if self.scrollback.len() > self.max_scrollback {
|
|
let overflow = self.scrollback.len() - self.max_scrollback;
|
|
self.scrollback.drain(0..overflow);
|
|
self.scrollback_wrapped.drain(0..overflow);
|
|
}
|
|
let consumed = self.bottom_trimmed_rows.min(copy_start);
|
|
self.bottom_trimmed_rows -= consumed;
|
|
}
|
|
|
|
for dst_row in 0..rows {
|
|
copy_row(&old_grid_rows[dst_row + copy_start], dst_row, &mut new_grid);
|
|
new_grid.line_wrapped[dst_row] = old_wrapped[dst_row + copy_start];
|
|
}
|
|
|
|
self.cursor.y = self.cursor.y.saturating_sub(copy_start).min(rows - 1);
|
|
self.high_water_row = self.high_water_row.saturating_sub(copy_start).min(rows - 1);
|
|
if let Some(saved) = &mut self.saved_cursor {
|
|
saved.y = saved.y.saturating_sub(copy_start).min(rows - 1);
|
|
}
|
|
}
|
|
std::cmp::Ordering::Equal => unreachable!(),
|
|
}
|
|
|
|
self.grid = new_grid;
|
|
self.scroll_top = 0;
|
|
self.scroll_bottom = rows;
|
|
}
|
|
|
|
/// Resize with content reflow when the column count changes.
|
|
///
|
|
/// Only scrollback is reflowed (joined wrapped lines, re-wrapped at new
|
|
/// width). Grid content is NOT reflowed — it stays at the same row
|
|
/// positions with simple truncate/pad for the width change. This keeps
|
|
/// TUI coordinate systems stable (apps redraw after SIGWINCH). Row count
|
|
/// changes are handled identically to `rows_only_resize`.
|
|
fn reflow_resize(&mut self, new_cols: usize, new_rows: usize) {
|
|
let old_rows = self.rows();
|
|
let was_full = self.used_rows() >= old_rows;
|
|
let has_content_near_bottom = self.last_non_empty_row() >= old_rows.saturating_sub(2);
|
|
let has_custom_scroll_region = self.scroll_top != 0 || self.scroll_bottom != old_rows;
|
|
let old_grid_rows: Vec<Vec<Cell>> = (0..old_rows)
|
|
.map(|row| self.grid.row_slice(row).to_vec())
|
|
.collect();
|
|
let old_wrapped: Vec<bool> = self.grid.line_wrapped.clone();
|
|
|
|
let mut logical_scrollback_lines: Vec<(Vec<Cell>, bool)> = Vec::new();
|
|
{
|
|
let mut current: Vec<Cell> = Vec::new();
|
|
for (i, row) in self.scrollback.iter().enumerate() {
|
|
let wrapped = self.scrollback_wrapped.get(i).copied().unwrap_or(false);
|
|
if wrapped {
|
|
current.extend_from_slice(row);
|
|
} else {
|
|
let mut end = row.len();
|
|
while end > 0 && row[end - 1].codepoint == ' ' {
|
|
end -= 1;
|
|
}
|
|
current.extend_from_slice(&row[..end]);
|
|
|
|
if current.is_empty() {
|
|
logical_scrollback_lines.push((vec![Cell::default(); new_cols], false));
|
|
} else {
|
|
let num_chunks = (current.len() + new_cols - 1) / new_cols;
|
|
for ci in 0..num_chunks {
|
|
let begin = ci * new_cols;
|
|
let end = (begin + new_cols).min(current.len());
|
|
let mut new_row = current[begin..end].to_vec();
|
|
new_row.resize(new_cols, Cell::default());
|
|
logical_scrollback_lines.push((new_row, ci < num_chunks - 1));
|
|
}
|
|
}
|
|
current.clear();
|
|
}
|
|
}
|
|
if !current.is_empty() {
|
|
let num_chunks = (current.len() + new_cols - 1) / new_cols;
|
|
for ci in 0..num_chunks {
|
|
let begin = ci * new_cols;
|
|
let end = (begin + new_cols).min(current.len());
|
|
let mut new_row = current[begin..end].to_vec();
|
|
new_row.resize(new_cols, Cell::default());
|
|
logical_scrollback_lines.push((new_row, ci < num_chunks - 1));
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut new_scrollback: Vec<Vec<Cell>> = logical_scrollback_lines
|
|
.iter()
|
|
.map(|(row, _)| row.clone())
|
|
.collect();
|
|
let mut new_scrollback_wrapped: Vec<bool> = logical_scrollback_lines
|
|
.iter()
|
|
.map(|(_, wrapped)| *wrapped)
|
|
.collect();
|
|
let mut new_grid = Grid::new(new_cols, new_rows);
|
|
|
|
let copy_row = |src: &[Cell], dst_row: usize, grid: &mut Grid| {
|
|
for col in 0..new_cols.min(src.len()) {
|
|
*grid.cell_mut(col, dst_row) = src[col];
|
|
}
|
|
};
|
|
|
|
match new_rows.cmp(&old_rows) {
|
|
std::cmp::Ordering::Greater => {
|
|
let pull_count = if was_full {
|
|
new_scrollback.len().min(new_rows - old_rows)
|
|
} else {
|
|
0
|
|
};
|
|
|
|
if pull_count > 0 {
|
|
let sb_start = new_scrollback.len() - pull_count;
|
|
let pulled_rows: Vec<Vec<Cell>> = new_scrollback.drain(sb_start..).collect();
|
|
let pulled_wrapped: Vec<bool> =
|
|
new_scrollback_wrapped.drain(sb_start..).collect();
|
|
for (i, row) in pulled_rows.iter().enumerate() {
|
|
copy_row(row, i, &mut new_grid);
|
|
new_grid.line_wrapped[i] = pulled_wrapped.get(i).copied().unwrap_or(false);
|
|
}
|
|
for (src_row, row) in old_grid_rows.iter().enumerate() {
|
|
copy_row(row, src_row + pull_count, &mut new_grid);
|
|
new_grid.line_wrapped[src_row + pull_count] = old_wrapped[src_row];
|
|
}
|
|
self.cursor.y += pull_count;
|
|
self.high_water_row += pull_count;
|
|
self.bottom_trimmed_rows = self.bottom_trimmed_rows.saturating_add(pull_count);
|
|
if let Some(saved) = &mut self.saved_cursor {
|
|
saved.y += pull_count;
|
|
}
|
|
} else if has_custom_scroll_region && has_content_near_bottom {
|
|
let shift = new_rows - old_rows;
|
|
for (src_row, row) in old_grid_rows.iter().enumerate() {
|
|
copy_row(row, src_row + shift, &mut new_grid);
|
|
new_grid.line_wrapped[src_row + shift] = old_wrapped[src_row];
|
|
}
|
|
self.cursor.y += shift;
|
|
self.high_water_row += shift;
|
|
if let Some(saved) = &mut self.saved_cursor {
|
|
saved.y += shift;
|
|
}
|
|
} else {
|
|
for (src_row, row) in old_grid_rows.iter().enumerate() {
|
|
copy_row(row, src_row, &mut new_grid);
|
|
new_grid.line_wrapped[src_row] = old_wrapped[src_row];
|
|
}
|
|
if was_full {
|
|
self.high_water_row = new_rows - 1;
|
|
}
|
|
}
|
|
}
|
|
std::cmp::Ordering::Less => {
|
|
let required_scrolling = (self.cursor.y + 1).saturating_sub(new_rows);
|
|
let lines_removed = old_rows - new_rows;
|
|
let mut copy_start = required_scrolling.min(lines_removed);
|
|
let has_text_below_cursor = self.last_non_empty_row() > self.cursor.y;
|
|
|
|
if (has_custom_scroll_region && has_content_near_bottom) || has_text_below_cursor {
|
|
copy_start = lines_removed;
|
|
} else if copy_start == 0 && self.bottom_trimmed_rows > 0 {
|
|
copy_start = self.bottom_trimmed_rows.min(lines_removed);
|
|
}
|
|
|
|
if copy_start > 0 {
|
|
for i in 0..copy_start {
|
|
let mut row = old_grid_rows[i].clone();
|
|
row.resize(new_cols, Cell::default());
|
|
row.truncate(new_cols);
|
|
new_scrollback.push(row);
|
|
new_scrollback_wrapped.push(old_wrapped[i]);
|
|
}
|
|
if new_scrollback.len() > self.max_scrollback {
|
|
let overflow = new_scrollback.len() - self.max_scrollback;
|
|
new_scrollback.drain(0..overflow);
|
|
new_scrollback_wrapped.drain(0..overflow);
|
|
}
|
|
let consumed = self.bottom_trimmed_rows.min(copy_start);
|
|
self.bottom_trimmed_rows -= consumed;
|
|
}
|
|
|
|
for dst_row in 0..new_rows {
|
|
copy_row(&old_grid_rows[dst_row + copy_start], dst_row, &mut new_grid);
|
|
new_grid.line_wrapped[dst_row] = old_wrapped[dst_row + copy_start];
|
|
}
|
|
|
|
self.cursor.y = self.cursor.y.saturating_sub(copy_start).min(new_rows - 1);
|
|
self.high_water_row = self.high_water_row.saturating_sub(copy_start).min(new_rows - 1);
|
|
if let Some(saved) = &mut self.saved_cursor {
|
|
saved.y = saved.y.saturating_sub(copy_start).min(new_rows - 1);
|
|
}
|
|
}
|
|
std::cmp::Ordering::Equal => {
|
|
for (src_row, row) in old_grid_rows.iter().enumerate() {
|
|
copy_row(row, src_row, &mut new_grid);
|
|
new_grid.line_wrapped[src_row] = old_wrapped[src_row];
|
|
}
|
|
}
|
|
}
|
|
|
|
if new_scrollback.len() > self.max_scrollback {
|
|
let overflow = new_scrollback.len() - self.max_scrollback;
|
|
new_scrollback.drain(0..overflow);
|
|
new_scrollback_wrapped.drain(0..overflow);
|
|
}
|
|
|
|
self.scrollback = new_scrollback.into();
|
|
self.scrollback_wrapped = new_scrollback_wrapped.into();
|
|
self.grid = new_grid;
|
|
self.cursor.x = self.cursor.x.min(new_cols.saturating_sub(1));
|
|
self.high_water_row = self.high_water_row.min(new_rows.saturating_sub(1));
|
|
self.cursor.pending_wrap = false;
|
|
self.scroll_top = 0;
|
|
self.scroll_bottom = new_rows;
|
|
|
|
if let Some(saved) = &mut self.saved_cursor {
|
|
saved.x = saved.x.min(new_cols.saturating_sub(1));
|
|
saved.y = saved.y.min(new_rows.saturating_sub(1));
|
|
saved.pending_wrap = false;
|
|
}
|
|
|
|
self.tabstops = vec![false; new_cols];
|
|
for i in (0..new_cols).step_by(8) {
|
|
self.tabstops[i] = true;
|
|
}
|
|
}
|
|
|
|
/// Get scrollback lines
|
|
pub fn scrollback(&self) -> &VecDeque<Vec<Cell>> {
|
|
&self.scrollback
|
|
}
|
|
|
|
pub fn scrollback_len(&self) -> usize {
|
|
self.scrollback.len()
|
|
}
|
|
|
|
/// Number of grid rows that have been written to (based on high water mark).
|
|
pub fn used_rows(&self) -> usize {
|
|
(self.high_water_row.max(self.cursor.y) + 1).min(self.rows())
|
|
}
|
|
|
|
pub fn last_non_empty_row(&self) -> usize {
|
|
for row in (0..self.rows()).rev() {
|
|
let slice = self.grid.row_slice(row);
|
|
for cell in slice {
|
|
if cell.codepoint != ' ' || cell.style != Style::default() {
|
|
return row;
|
|
}
|
|
}
|
|
}
|
|
0
|
|
}
|
|
|
|
/// Row slice by virtual row index where:
|
|
/// - `0..scrollback_len` maps to historical rows (oldest first)
|
|
/// - `scrollback_len..total_rows` maps to active grid rows.
|
|
pub fn row_slice_virtual(&self, row: usize) -> Option<&[Cell]> {
|
|
if row < self.scrollback.len() {
|
|
return self.scrollback.get(row).map(|row| row.as_slice());
|
|
}
|
|
let grid_row = row - self.scrollback.len();
|
|
if grid_row < self.rows() {
|
|
return Some(self.grid.row_slice(grid_row));
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn scrollback_rollover_keeps_recent_rows_in_order() {
|
|
let mut screen = Screen::new(1, 2, true);
|
|
screen.max_scrollback = 3;
|
|
|
|
for ch in ['a', 'b', 'c', 'd', 'e'] {
|
|
screen.write_char(ch);
|
|
screen.do_carriage_return();
|
|
screen.do_linefeed();
|
|
}
|
|
|
|
assert_eq!(screen.scrollback_len(), 3);
|
|
assert_eq!(screen.scrollback()[0][0].codepoint, 'b');
|
|
assert_eq!(screen.scrollback()[1][0].codepoint, 'c');
|
|
assert_eq!(screen.scrollback()[2][0].codepoint, 'd');
|
|
assert_eq!(screen.row_slice_virtual(3).unwrap()[0].codepoint, 'e');
|
|
}
|
|
}
|