makepad/libs/terminal_core/src/screen.rs
2026-04-05 11:28:47 +02:00

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');
}
}