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


Predict First

  1. In a 10-column terminal you write exactly 10 characters, then \n, then X. Which row is X on?
  2. You set \x1b[41m then \x1b[2J. What color is the screen?
  3. CSI 5;20r then 30 lines of output. How many lines are in scrollback?
  4. 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

  1. Benchmark flat vs. ring storage. cat a 100 MB file through both. Report lines/second.
  2. Trim trailing blanks on lines entering scrollback; measure the memory saving on a real recording.
  3. Implement DECALN (ESC # 8) and use it as a full-screen smoke test.
  4. Property-test the wide-character invariant with 100,000 random operations.
  5. Implement snapshot_json and use it to diff two terminals — the seed of differential testing.
  6. Add --debug-cursor, logging every cursor movement with its cause (which sequence).

Deliverables

  • terminal-core with only terminal-protocol, unicode-width, and bitflags as dependencies, verified with cargo 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, and snapshot_json.
  • The two structural invariant tests passing over 10,000 random operations.
  • cargo build --target wasm32-unknown-unknown -p terminal-core succeeds.

Validation / Self-check

  1. State the pending-wrap rule and every event that clears the flag.
  2. Why does erase use the current background? What surprising behavior does that explain?
  3. Why does CSI 2 J not move the cursor?
  4. Which scrolls feed scrollback and which do not?
  5. Why must all row access go through one function?
  6. What is the wide/spacer invariant, and which four operations can break it?
  7. Why does cursor movement damage two rows?
  8. Why does terminal-core have no OS dependencies, and how is that enforced?
  9. What does erase_style() keep and drop, and why is the answer terminal-specific?
  10. Your terminal panics with an index-out-of-bounds at column 80 in an 80-column grid. Where do you look first?

Next: Lab 8 — The Headless Emulator.