Lab 7: The Screen Grid (Milestone 5)
Background
terminal-core is where actions become a screen. This lab builds the Grid, the Cursor, and the
Terminal that implements Perform — and it implements the four features that separate a real
terminal from a demo: pending wrap, scroll regions, erase with the current background,
and damage tracking.
Why This Lab Matters
- This crate is the reusable core. It compiles to WebAssembly, runs in the mux server with no display, and drives the test harness. Every constraint it accepts here pays off four times.
- Pending wrap is the single most visible screen bug, and almost nobody gets it right first try.
- The boundary cases — last column, region edges, cursor at row 0 — are where every real bug lives.
Prerequisites
- Lab 6 complete; the parser produces actions.
- The Screen Model, The CSI Catalog, and SGR and Color read.
Predict First
- In a 10-column terminal you write exactly 10 characters, then
\n, thenX. Which row isXon? - You set
\x1b[41mthen\x1b[2J. What color is the screen? CSI 5;20rthen 30 lines of output. How many lines are in scrollback?- The cursor is at row 3. You send
\x1b[10;1H. How many rows are damaged?
Step 1: The Types
cargo new --lib crates/terminal-core --name terminal-core
[dependencies]
terminal-protocol = { path = "../terminal-protocol" }
unicode-width = "0.1"
bitflags = "2"
# NOTHING else. No libc, no nix, no tokio. Enforced in CI with:
# cargo tree -p terminal-core | grep -E 'nix|rustix|libc|mio|tokio|winit'
#![allow(unused)] fn main() { /// One screen cell. Copy and small on purpose: an 80×24 screen holds 1,920 of /// these and a scrollback holds hundreds of thousands. A String here would mean /// a heap allocation per cell. #[derive(Copy, Clone, PartialEq, Eq)] pub struct Cell { /// The base character. ' ' when empty; '\0' marks a wide-char spacer. ch: char, style: Style, flags: CellFlags, // WIDE | SPACER | HAS_COMBINING /// Index into the grid's combining table when HAS_COMBINING is set. combining: u16, } pub struct Line { cells: Vec<Cell>, /// True when this line ended by WRAPPING rather than by an explicit newline. /// Needed for copy/paste (join wrapped lines) and for reflow. pub wrapped: bool, } pub struct Grid { rows: usize, cols: usize, lines: Vec<Line>, /// Rotation offset, so scroll_up is O(cols) instead of O(rows*cols). /// EVERY access goes through Grid::line(); nothing else may touch `lines`. first: usize, } pub struct Cursor { pub row: usize, pub col: usize, pub style: Style, /// Deferred-wrap flag. Set when a character lands in the last column. /// Omitting this inserts a spurious blank line after every full-width line. pub pending_wrap: bool, } }
Step 2: The Indexing Rule
#![allow(unused)] fn main() { impl Grid { /// The ONLY place that maps a screen row to storage. Everything goes through /// here, so the storage strategy (flat vs. ring) can change without touching /// a single escape-sequence handler. #[inline] fn phys(&self, row: usize) -> usize { debug_assert!(row < self.rows, "row {row} out of range (rows={})", self.rows); (self.first + row) % self.lines.len() } pub fn line(&self, row: usize) -> &Line { &self.lines[self.phys(row)] } pub fn line_mut(&mut self, row: usize) -> &mut Line { let p = self.phys(row); &mut self.lines[p] } /// Scroll the region up by n. O(n * cols) with the ring, not O(rows * cols). pub fn scroll_region_up(&mut self, top: usize, bottom: usize, n: usize) -> Vec<Line> { let n = n.min(bottom - top + 1); let mut evicted = Vec::with_capacity(n); if top == 0 && bottom == self.rows - 1 { // Full-screen scroll: just rotate the ring and recycle the lines. for _ in 0..n { let p = self.phys(0); evicted.push(std::mem::replace(&mut self.lines[p], Line::blank(self.cols))); self.first = (self.first + 1) % self.lines.len(); } } else { // Sub-region scroll: an actual move. Correct, and rarer. for r in top..=bottom { if r + n <= bottom { let src = self.phys(r + n); let dst = self.phys(r); self.lines.swap(src, dst); } else { let p = self.phys(r); self.lines[p] = Line::blank(self.cols); } } } evicted } } }
Step 3: Printing, with Pending Wrap
This is the function that must be right.
#![allow(unused)] fn main() { impl Terminal { fn print_char(&mut self, c: char) { let width = cell_width(c); // Width 0: a combining mark or ZWJ. Absorb into the previous cell. if width == 0 { self.absorb_combining(c); return; } // ── 1. Resolve a PENDING WRAP before doing anything else. ────────── if self.cursor.pending_wrap && self.modes.contains(Mode::AUTO_WRAP) { self.screen.line_mut(self.cursor.row).wrapped = true; self.line_feed(); // may scroll the region self.cursor.col = 0; self.cursor.pending_wrap = false; } // ── 2. A wide character must not be split across the right margin. ── if width == 2 && self.cursor.col + 1 >= self.cols { if self.modes.contains(Mode::AUTO_WRAP) { // Leave the last cell blank and wrap. self.screen.line_mut(self.cursor.row).wrapped = true; self.line_feed(); self.cursor.col = 0; } else { return; // DECAWM off: drop it. Documented choice. } } // ── 3. Write, preserving the wide/spacer invariant. ──────────────── self.write_cell_preserving_invariant(c, width); self.damage.mark(self.cursor.row); // ── 4. Advance, or SET pending wrap. This is THE rule. ───────────── let next = self.cursor.col + width as usize; if next >= self.cols { self.cursor.col = self.cols - 1; self.cursor.pending_wrap = self.modes.contains(Mode::AUTO_WRAP); } else { self.cursor.col = next; self.cursor.pending_wrap = false; } } /// Writing over half a wide character must clear both halves, or an orphan /// spacer survives and breaks column arithmetic forever after. fn write_cell_preserving_invariant(&mut self, c: char, width: u8) { let (row, col) = (self.cursor.row, self.cursor.col); let line = self.screen.line_mut(row); // Overwriting a spacer? Clear its wide partner to the left. if line.cells[col].flags.contains(CellFlags::SPACER) && col > 0 { line.cells[col - 1] = Cell::blank(self.cursor.style); } // Overwriting a wide char? Clear its spacer to the right. if line.cells[col].flags.contains(CellFlags::WIDE) && col + 1 < self.cols { line.cells[col + 1] = Cell::blank(self.cursor.style); } // Same for the cell a width-2 write is about to cover. if width == 2 && col + 1 < self.cols { if line.cells[col + 1].flags.contains(CellFlags::WIDE) && col + 2 < self.cols { line.cells[col + 2] = Cell::blank(self.cursor.style); } line.cells[col + 1] = Cell::spacer(self.cursor.style); } line.cells[col] = Cell::new(c, self.cursor.style, width); } } }
Step 4: Implementing Perform
#![allow(unused)] fn main() { impl Perform for Terminal { fn print(&mut self, c: char) { self.print_char(c); } fn execute(&mut self, byte: u8) { // Every one of these clears pending_wrap. match byte { 0x07 => self.bell(), 0x08 => { self.cursor.col = self.cursor.col.saturating_sub(1); self.cursor.pending_wrap = false; } // BS moves, does NOT erase 0x09 => self.horizontal_tab(), 0x0a | 0x0b | 0x0c => { self.line_feed(); self.cursor.pending_wrap = false; } 0x0d => { self.cursor.col = 0; self.cursor.pending_wrap = false; } 0x0e => self.charset.invoke_g1(), 0x0f => self.charset.invoke_g0(), _ => {} } } fn csi_dispatch(&mut self, params: &Params, inter: &[u8], private: Option<u8>, action: char) { // Cursor motion clears pending wrap. Do it once, here, rather than in // twelve handlers — forgetting it in one is a subtle, rare bug. if matches!(action, 'A'|'B'|'C'|'D'|'E'|'F'|'G'|'H'|'f'|'d'|'I'|'Z') { self.cursor.pending_wrap = false; } match (private, inter, action) { (None, [], 'A') => self.cursor_up(params.get_or(0, 1) as usize), (None, [], 'B') => self.cursor_down(params.get_or(0, 1) as usize), (None, [], 'C') => self.cursor_forward(params.get_or(0, 1) as usize), (None, [], 'D') => self.cursor_back(params.get_or(0, 1) as usize), (None, [], 'H') | (None, [], 'f') => self.cursor_position( params.get_or(0, 1) as usize, params.get_or(1, 1) as usize), (None, [], 'J') => self.erase_in_display(params.get_or(0, 0)), (None, [], 'K') => self.erase_in_line(params.get_or(0, 0)), (None, [], 'm') => self.handle_sgr(params), (None, [], 'r') => self.set_scroll_region(params), (None, [], 'n') => self.device_status_report(params.get_or(0, 0)), (Some(b'?'), [], 'h') => self.set_dec_modes(params, true), (Some(b'?'), [], 'l') => self.set_dec_modes(params, false), (None, [b'!'], 'p') => self.soft_reset(), // Unknown sequences are IGNORED, never fatal. This is where forward // compatibility lives: a new xterm sequence must not break you. _ => self.debug_unhandled(private, inter, action, params), } } // esc_dispatch, osc_dispatch, hook/put/unhook ... } }
Step 5: Erase, With the Right Background
#![allow(unused)] fn main() { fn erase_in_display(&mut self, mode: u16) { // Erased cells take the CURRENT SGR background, not the default. // This is why `printf '\033[41m\033[2J'` gives a red screen. let blank = Cell::blank(self.cursor.style.erase_style()); match mode { 0 => { // cursor to end of screen self.erase_line_range(self.cursor.row, self.cursor.col, self.cols, blank); for r in self.cursor.row + 1..self.rows { self.erase_line_range(r, 0, self.cols, blank); } self.damage.mark_range(self.cursor.row, self.rows - 1); } 1 => { // start of screen to cursor (INCLUSIVE of the cursor cell) for r in 0..self.cursor.row { self.erase_line_range(r, 0, self.cols, blank); } self.erase_line_range(self.cursor.row, 0, self.cursor.col + 1, blank); self.damage.mark_range(0, self.cursor.row); } 2 => { // whole screen — the CURSOR DOES NOT MOVE for r in 0..self.rows { self.erase_line_range(r, 0, self.cols, blank); } self.damage.mark_all(); } 3 => { self.screen.scrollback.clear(); } // xterm extension _ => {} } } }
Note:
erase_style()keeps the background and drops the foreground and most attributes. Which attributes survive an erase is genuinely inconsistent between terminals; xterm keeps background and (optionally) reverse. Pick "background only" and document it.
Step 6: Snapshots
#![allow(unused)] fn main() { impl Terminal { /// Deterministic plain-text rendering. The workhorse of every test. pub fn snapshot_text(&self) -> String { let mut s = String::new(); for r in 0..self.rows { let line = self.screen.line(r); let mut row = String::new(); for c in 0..self.cols { let cell = &line.cells[c]; if cell.flags.contains(CellFlags::SPACER) { continue; } // wide char's second half row.push_str(cell.grapheme()); } // Trim trailing blanks so snapshots are stable and diffable. s.push_str(row.trim_end()); s.push('\n'); } s } /// Includes styles and flags. For debugging and for style-sensitive tests. pub fn snapshot_debug(&self) -> String { /* one line per cell that differs from default */ } /// Machine-readable, for the mux protocol and cross-tool comparison. pub fn snapshot_json(&self) -> String { /* rows of styled runs */ } } }
Expected Output
$ mini-term run --rows 5 --cols 20 -- printf 'hello\nworld\n'
hello
world
$ mini-term run --rows 3 --cols 10 -- printf '0123456789ABC'
0123456789
ABC
$ mini-term run --rows 3 --cols 10 --format debug -- printf '0123456789'
cursor: row=0 col=9 pending_wrap=TRUE
row 0: "0123456789"
row 1: ""
row 2: ""
That pending_wrap=TRUE with col=9 is the whole lesson of this lab.
Debugging Steps
Every full line of output is followed by a blank line
No pending wrap. The most common Milestone 5 bug.
Colors leak into cleared regions
erase is using Style::default() instead of the cursor's current background. Or the reverse: the
screen goes red after printf '\033[41m\033[2J' and you think it is a bug — it is not.
top renders but the header scrolls away
Scroll region not implemented, or LF scrolls the screen instead of the region.
vim exits and your scrollback is full of vim's content
The alternate screen is feeding scrollback. It must not.
Off-by-one at the right edge; panics on index cols
Clamp in exactly one place. Add debug_assert! in phys() and in the cell accessor.
Wide characters leave phantom blanks
The spacer invariant is being violated by an overwrite or an erase. Run the property test.
Experiment
CLAIM. Pending wrap is externally observable via CSI 6n, and real terminals implement it.
METHOD.
# In a terminal you have resized to exactly 20 columns:
printf '12345678901234567890\033[6n'; read -r -d R p; echo; echo "cursor=${p#*[}"
# Expect row;20 — NOT row+1;1
printf '12345678901234567890\nNEXT\n'
# NEXT must be on the very next line, with no blank between.
# Now compare against your own implementation:
mini-term run --rows 5 --cols 20 --format debug -- printf '12345678901234567890'
PREDICTION. Column 20 or column 1? Which row?
Test
#![allow(unused)] fn main() { #[test] fn pending_wrap_defers_the_line_break() { let mut t = Terminal::new(5, 10); t.advance(b"0123456789"); assert_eq!((t.cursor().row, t.cursor().col), (0, 9)); assert!(t.cursor().pending_wrap); t.advance(b"\n"); assert_eq!(t.cursor().row, 1, "LF consumes the pending wrap, no extra line"); assert_eq!(t.snapshot_text(), "0123456789\n\n\n\n\n"); } #[test] fn erase_uses_the_current_background() { let mut t = Terminal::new(3, 10); t.advance(b"\x1b[41m\x1b[2J"); for c in 0..10 { assert_eq!(t.screen().line(0).cells[c].style().bg, Color::Indexed(1)); } } #[test] fn ed_2_does_not_move_the_cursor() { let mut t = Terminal::new(5, 10); t.advance(b"\x1b[3;5H\x1b[2J"); assert_eq!((t.cursor().row, t.cursor().col), (2, 4)); } #[test] fn scrolling_off_the_top_feeds_scrollback() { let mut t = Terminal::new(3, 10); for i in 0..10 { t.advance(format!("line{i}\n").as_bytes()); } assert!(t.screen().scrollback().len() >= 7); assert!(t.screen().scrollback()[0].text().starts_with("line0")); } #[test] fn cursor_position_clamps_at_the_boundaries() { let mut t = Terminal::new(5, 10); t.advance(b"\x1b[999;999H"); assert_eq!((t.cursor().row, t.cursor().col), (4, 9)); t.advance(b"\x1b[0;0H"); // 0 is clamped up to 1 assert_eq!((t.cursor().row, t.cursor().col), (0, 0)); } #[test] fn every_row_always_has_exactly_cols_cells() { // A structural invariant. Violating it turns into panics much later, far // from the cause. let mut t = Terminal::new(10, 20); for op in random_ops(10_000) { t.advance(&op); } for r in 0..10 { assert_eq!(t.screen().line(r).cells.len(), 20); } } #[test] fn cursor_is_always_in_bounds() { let mut t = Terminal::new(10, 20); for op in random_ops(10_000) { t.advance(&op); assert!(t.cursor().row < 10 && t.cursor().col < 20, "cursor escaped: {:?}", t.cursor()); } } }
Challenge Extensions
- Benchmark flat vs. ring storage.
cata 100 MB file through both. Report lines/second. - Trim trailing blanks on lines entering scrollback; measure the memory saving on a real recording.
- Implement
DECALN(ESC # 8) and use it as a full-screen smoke test. - Property-test the wide-character invariant with 100,000 random operations.
- Implement
snapshot_jsonand use it to diff two terminals — the seed of differential testing. - Add
--debug-cursor, logging every cursor movement with its cause (which sequence).
Deliverables
-
terminal-corewith onlyterminal-protocol,unicode-width, andbitflagsas dependencies, verified withcargo tree. - Pending wrap implemented and tested with all three tests above.
- Erase honoring the current background.
- Scroll regions, with sub-region scrolls excluded from scrollback.
- Damage tracking, including the two-row rule for cursor movement.
-
snapshot_text,snapshot_debug, andsnapshot_json. - The two structural invariant tests passing over 10,000 random operations.
-
cargo build --target wasm32-unknown-unknown -p terminal-coresucceeds.
Validation / Self-check
- State the pending-wrap rule and every event that clears the flag.
- Why does erase use the current background? What surprising behavior does that explain?
- Why does
CSI 2 Jnot move the cursor? - Which scrolls feed scrollback and which do not?
- Why must all row access go through one function?
- What is the wide/spacer invariant, and which four operations can break it?
- Why does cursor movement damage two rows?
- Why does
terminal-corehave no OS dependencies, and how is that enforced? - What does
erase_style()keep and drop, and why is the answer terminal-specific? - Your terminal panics with an index-out-of-bounds at column 80 in an 80-column grid. Where do you look first?