The Screen Model: Grid, Cursor, Wrap, Scrollback, Resize
The parser tells you what happened. The screen model decides what the screen now looks like. It
is the heart of terminal-core, and it is harder than the parser — the parser is a well-specified
state machine, while the screen model is forty years of accumulated behavior with edge cases that
only appear at the boundaries.
This chapter covers the data structures, the cursor (including the pending-wrap rule that everyone gets wrong), scrolling and the scroll region, scrollback, and resize.
The Data Structures
Terminal
├── parser: Parser (terminal-protocol)
├── screen: Screen
│ ├── primary: Grid + scrollback
│ ├── alternate: Grid (NO scrollback, by definition)
│ └── active: BufferKind
├── cursor: Cursor { row, col, style, pending_wrap, origin_mode }
├── saved: Option<SavedCursor> (DECSC / DECRC)
├── modes: TerminalModes (bitflags)
├── scroll_region: (top, bottom) (DECSTBM, 0-based inclusive)
├── tab_stops: Vec<bool> (one per column)
├── title: String (OSC 0/2)
├── replies: Vec<u8> (DSR/DA answers, drained by the caller)
└── damage: DamageSet (which rows changed)
Grid (rows × cols)
┌──────────────────────────────────────────────────────┐
│ Line 0: [Cell][Cell][Cell]...[Cell] wrapped: bool │
│ Line 1: [Cell][Cell][Cell]...[Cell] wrapped: bool │
│ ... │
│ Line R-1:[Cell][Cell][Cell]...[Cell] wrapped: bool │
└──────────────────────────────────────────────────────┘
Cell { ch: char, style: Style, flags: WIDE|SPACER|COMBINING, combining: u16 }
The wrapped flag on a Line
Each line records whether it ended because the text wrapped or because there was an explicit newline. This one bit is load-bearing:
| Consumer | Why it needs wrapped |
|---|---|
| Copy/paste | A wrapped line must be joined to the next when copied; a hard-newline line must not. Without this, copying a long path out of your terminal inserts a spurious newline — the single most-complained-about terminal bug. |
| Resize with reflow | You can only reflow lines that were wrapped |
| Search | A match spanning a wrap should be found |
Store it. It costs one bit per line and it is the difference between a usable terminal and an annoying one.
Storage strategy: flat Vec vs. a ring of lines
FLAT Vec<Cell>, indexed row*cols + col
✓ trivial; cache-friendly for row scans
✗ scroll_up(1) = memmove of the ENTIRE grid, every line, every time
Vec<Line> with a ring / rotating index
✓ scroll_up(1) = advance an index + clear one line: O(cols), not O(rows*cols)
✗ index arithmetic everywhere; must be encapsulated
top scrolls every second. cat on a large file scrolls thousands of times per second. Start flat,
benchmark, and move to the ring when the benchmark says so — but encapsulate the indexing from day
one so the change is local:
#![allow(unused)] fn main() { impl Grid { /// The ONLY place that maps (row, col) to storage. Everything else goes /// through this, so the storage strategy can change without touching /// escape-sequence handling. #[inline] fn index(&self, row: usize, col: usize) -> usize { debug_assert!(row < self.rows && col < self.cols); ((self.first_line + row) % self.rows) * self.cols + col } } }
The Cursor, and the Pending-Wrap Rule
The rule
When a character is written into the last column, the cursor does not move to the next line. It stays in the last column, and a pending wrap flag is set. The wrap happens when the next printable character arrives.
This is DECAWM's deferred-wrap behavior, and it is correct, mandatory, and non-obvious.
Why it exists
An 80-column terminal. A program writes exactly 80 characters.
NAÏVE (wrap immediately):
after 80 chars → cursor moves to row+1, col 0
the program now writes "\n"
→ cursor moves to row+2
→ A BLANK LINE APPEARS that the program never asked for.
CORRECT (pending wrap):
after 80 chars → cursor stays at (row, 79), pending_wrap = true
the program writes "\n"
→ LF clears pending_wrap and moves to row+1, col 0
→ no blank line. Correct.
OR the program writes another character 'X'
→ pending_wrap is set, so: wrap first (row+1, col 0), clear the flag,
mark row as wrapped=true, then write 'X'.
Without pending wrap, every full-width table, every 80-column banner, and every progress bar that fills the line gains a spurious blank line. It is the single most visible screen-model bug.
The rules for the flag
| Event | Effect on pending_wrap |
|---|---|
| Write a char into the last column | Set |
| Write a char anywhere else | Clear |
| A printable char arrives while set | Wrap first (mark the line wrapped), then write, then clear |
LF, CR, BS, HT | Clear (any explicit cursor motion clears it) |
Any explicit cursor positioning (CUP, CUF, CUB, …) | Clear |
DECAWM (?7) is reset | No wrapping at all; the cursor sticks in the last column and overwrites |
| Resize | Clear (safest) |
#![allow(unused)] fn main() { fn write_grapheme(&mut self, c: char, width: u8) { // 1. Resolve any pending wrap BEFORE writing. if self.cursor.pending_wrap && self.modes.contains(Mode::AUTO_WRAP) { self.screen.line_mut(self.cursor.row).wrapped = true; // for copy/paste + reflow self.linefeed(); // may scroll self.cursor.col = 0; self.cursor.pending_wrap = false; } // 2. A wide character that does not fit must not be split. if width == 2 && self.cursor.col + 1 >= self.cols { if self.modes.contains(Mode::AUTO_WRAP) { self.screen.line_mut(self.cursor.row).wrapped = true; self.linefeed(); self.cursor.col = 0; } else { return; // DECAWM off: silently drop. Document this choice. } } // 3. Write, preserving the wide/spacer invariant. self.screen.write_cell(self.cursor.row, self.cursor.col, c, self.cursor.style, width); self.damage.mark(self.cursor.row); // 4. Advance, or set the pending-wrap flag. let next = self.cursor.col + width as usize; if next >= self.cols { if self.modes.contains(Mode::AUTO_WRAP) { self.cursor.col = self.cols - 1; self.cursor.pending_wrap = true; // ← THE RULE } else { self.cursor.col = self.cols - 1; // stick, overwrite in place } } else { self.cursor.col = next; self.cursor.pending_wrap = false; } } }
Test it explicitly
#![allow(unused)] fn main() { #[test] fn writing_exactly_cols_characters_sets_pending_wrap_without_moving() { // The pending-wrap rule. Omitting it inserts a spurious blank line after // every full-width line of output. let mut t = Terminal::new(5, 10); t.advance(b"0123456789"); // exactly 10 characters in a 10-col grid assert_eq!(t.cursor().row, 0, "cursor must NOT have moved to the next row"); assert_eq!(t.cursor().col, 9, "cursor stays in the last column"); assert!(t.cursor().pending_wrap, "pending wrap must be set"); } #[test] fn newline_after_a_full_line_does_not_produce_a_blank_line() { // The user-visible consequence. let mut t = Terminal::new(5, 10); t.advance(b"0123456789\nnext"); assert_eq!(t.screen().row_text(0), "0123456789"); assert_eq!(t.screen().row_text(1), "next ", "no blank line may appear"); } #[test] fn the_next_printable_char_triggers_the_wrap() { let mut t = Terminal::new(5, 10); t.advance(b"0123456789X"); assert_eq!(t.screen().row_text(0), "0123456789"); assert_eq!(t.screen().row_text(1), "X "); assert_eq!(t.cursor().col, 1); assert!(t.screen().line(0).wrapped, "the line must be marked as wrapped"); } }
Scrolling and the Scroll Region
The scroll region (DECSTBM)
CSI Pt ; Pb r sets the top and bottom margins. Scrolling is confined to that region; lines
outside it never move.
CSI 5;20r in a 24-row terminal:
row 0 ┌──────────────────┐ ← outside: a static header
row 1 │ │
row 2 │ │
row 3 │ │
row 4 ├──────────────────┤ ← scroll region TOP (0-based row 4)
row 5 │ │
... │ scrolls here │
row 19 ├──────────────────┤ ← scroll region BOTTOM (0-based row 19)
row 20 │ │ ← outside: a static footer / status line
... │ │
row 23 └──────────────────┘
This is how vim's status line stays put while text scrolls, and how less and top keep a header.
| Rule | Detail |
|---|---|
| Parameters are 1-based in the sequence, and you store them 0-based | Off-by-one heaven; convert in exactly one place |
CSI r with no params | Reset to the full screen |
| Setting DECSTBM homes the cursor | To (0,0), or to the region top if origin mode is set. Easy to forget. |
| Bottom must be > top | Otherwise ignore the whole sequence |
LF at the bottom margin scrolls the region, not the screen | And only lines within the region move |
| Only the primary screen's scroll-out goes to scrollback | And only when the region is the full screen — otherwise the lines are simply discarded |
That last rule surprises people: if a program sets a scroll region and then scrolls, the lines
scrolled out do not enter scrollback. That is correct — they were part of a sub-region the
program is managing, not the terminal's history. It is why you cannot scroll back through vim's
buffer.
Origin mode (DECOM, ?6)
When set, cursor positioning is relative to the scroll region, and the cursor cannot leave it.
CSI 1;1H moves to the region's top-left, not the screen's.
#![allow(unused)] fn main() { /// Absolute row for a CUP parameter, honoring origin mode. fn resolve_row(&self, param_row_1based: u16) -> usize { let r = (param_row_1based.max(1) - 1) as usize; if self.modes.contains(Mode::ORIGIN) { (self.scroll_top + r).min(self.scroll_bottom) } else { r.min(self.rows - 1) } } }
The scroll operations
| Operation | Sequence | Effect |
|---|---|---|
| Index / linefeed | LF, IND (ESC D) | Cursor down; if at the region bottom, scroll the region up by 1 |
| Reverse index | RI (ESC M) | Cursor up; if at the region top, scroll the region down by 1 |
| Scroll up | SU (CSI Ps S) | Scroll the region up by Ps, cursor unmoved |
| Scroll down | SD (CSI Ps T) | Scroll the region down by Ps, cursor unmoved |
| Insert line | IL (CSI Ps L) | Insert Ps blank lines at the cursor; lines below shift down within the region |
| Delete line | DL (CSI Ps M) | Delete Ps lines at the cursor; lines below shift up within the region |
#![allow(unused)] fn main() { fn scroll_region_up(&mut self, n: usize) { let top = self.scroll_top; let bottom = self.scroll_bottom; // inclusive let n = n.min(bottom - top + 1); // Lines leaving the top go to scrollback ONLY on the primary screen AND // only when the region is the whole screen. A program-managed sub-region's // discarded lines are not terminal history. if self.screen.active == BufferKind::Primary && top == 0 && bottom == self.rows - 1 { for r in top..top + n { self.screen.scrollback.push_back(self.screen.take_line(r)); if self.screen.scrollback.len() > self.scrollback_limit { self.screen.scrollback.pop_front(); } } } self.screen.rotate_region_up(top, bottom, n); self.damage.mark_range(top, bottom); } }
Scrollback
| Property | Value |
|---|---|
| Storage | VecDeque<Line> — push at the back, evict from the front. Never Vec with remove(0). |
| Limit | Configurable; 10,000 lines is a common default. Memory is limit × cols × sizeof(Cell). |
| Alternate screen | None. Full stop. |
| Sub-region scrolls | Do not contribute |
| Cleared by | CSI 3 J (xterm extension: erase scrollback), and RIS (ESC c) |
Memory arithmetic, so you know what you are signing up for:
10,000 lines × 200 cols × 16 bytes/cell = 32 MB per terminal.
A mux server with 20 panes = 640 MB.
Mitigations real terminals use:
• trim trailing blanks when a line enters scrollback (usually a big win)
• run-length or compressed storage for scrollback lines
• a lower default limit
Measure before choosing.
Resize: The Genuinely Hard Part
There is no universally correct answer. There are three policies, and you must pick one, implement it consistently, and document it.
Policy A: Truncate (the simple one)
Wider: pad each line with blanks on the right
Narrower: cut each line at the new width; the excess is LOST
Taller: add blank lines at the bottom (or pull from scrollback — decide)
Shorter: push the top lines into scrollback
Simple, predictable, and lossy. Start here.
Policy B: Reflow (what users expect)
Before (width 20):
"the quick brown fox " wrapped=true
"jumps over the lazy " wrapped=true
"dog" wrapped=false
Resize to width 40 — join the wrapped lines and re-split:
"the quick brown fox jumps over the lazy " wrapped=true
"dog" wrapped=false
Requires the wrapped flag (now you see why it matters), and raises hard questions:
- Where does the cursor go? It must track the character it was on, through the reflow.
- What about the scrollback? Reflowing 10,000 lines on every resize is slow; not reflowing them makes history look wrong.
- What about wide characters at the new boundary?
- What about a line with trailing spaces — are they content or padding?
Policy C: No reflow on the alternate screen
Universal: the alternate screen is never reflowed. It is cleared or truncated, and the program is
sent SIGWINCH to redraw. This is correct because a full-screen program owns its own layout — vim
does not want your idea of how its buffer should rewrap.
#![allow(unused)] fn main() { pub fn resize(&mut self, new_rows: usize, new_cols: usize) { // The alternate screen is never reflowed. The program redraws on SIGWINCH. self.screen.alternate.resize_truncate(new_rows, new_cols); match self.resize_policy { ResizePolicy::Truncate => self.screen.primary.resize_truncate(new_rows, new_cols), ResizePolicy::Reflow => self.screen.primary.resize_reflow(new_rows, new_cols, &mut self.cursor), } // Scroll region: reset, because the old margins may be out of range and // there is no sensible way to scale them. self.scroll_top = 0; self.scroll_bottom = new_rows - 1; // Tab stops: extend with the default every-8 pattern when widening. self.tab_stops.resize(new_cols, false); for c in (0..new_cols).step_by(8) { self.tab_stops[c] = true; } self.cursor.row = self.cursor.row.min(new_rows - 1); self.cursor.col = self.cursor.col.min(new_cols - 1); self.cursor.pending_wrap = false; self.damage.mark_all(); } }
Warning: Resize is where terminals go to die. Alacritty, kitty, and WezTerm have all had multi-year-old open issues about reflow edge cases. Do not attempt reflow until truncate works and is tested. When you do attempt it, write the cursor-tracking test first — it is the part that breaks.
Damage Tracking
The renderer needs to know what changed. The cheapest useful granularity is per row.
#![allow(unused)] fn main() { #[derive(Default)] pub struct DamageSet { rows: Vec<bool>, all: bool, } impl DamageSet { pub fn mark(&mut self, row: usize) { self.rows[row] = true; } pub fn mark_range(&mut self, a: usize, b: usize) { for r in a..=b { self.rows[r] = true; } } pub fn mark_all(&mut self) { self.all = true; } pub fn iter_dirty(&self) -> impl Iterator<Item = usize> + '_ { /* ... */ } pub fn clear(&mut self) { self.rows.fill(false); self.all = false; } } }
Rules:
| Operation | Damage |
|---|---|
| Write a cell | That row |
| Cursor move | The old row and the new row (the cursor is drawn, so both must repaint) |
| Scroll | The whole region |
| Erase | The affected rows |
Mode change that affects display (alt screen, ?25 cursor visibility) | All |
| Resize | All |
Tip: Cursor movement damaging two rows is easy to forget and produces a "ghost cursor" artifact — the old cursor block never gets erased. It is the first rendering bug you will hit in Section 3.
Experiment
CLAIM. Pending wrap is observable from the shell, and its absence is visible.
METHOD.
# Make the terminal exactly 20 columns (or use your own emulator with --cols 20).
printf '12345678901234567890' # exactly 20 chars
printf '\033[6n' # query the cursor
read -r -d R pos; echo; echo "cursor: ${pos#*[}"
# Expect row N, column 20 (1-based) — NOT row N+1, column 1.
# Now the visible consequence:
printf '12345678901234567890\nNEXT\n'
# "NEXT" must be on the line IMMEDIATELY after the digits, with NO blank line.
# And with 21 characters:
printf '123456789012345678901\nNEXT\n'
# the 21st char is on its own line, then NEXT.
PREDICTION. Write down, before running: after exactly 20 characters in a 20-column terminal,
what column does CSI 6n report? What happens if you then print one more character?
Test
#![allow(unused)] fn main() { #[test] fn scroll_region_confines_scrolling() { // CSI 5;20r then filling past the bottom must not move rows 0-3 or 20-23. let mut t = Terminal::new(24, 20); t.advance(b"HEADER\x1b[5;20r"); t.advance(b"\x1b[24;1H"); // outside the region t.advance(b"FOOTER"); t.advance(b"\x1b[20;1H"); // the region's bottom row for _ in 0..30 { t.advance(b"line\n"); } assert!(t.screen().row_text(0).starts_with("HEADER"), "header must not scroll"); assert!(t.screen().row_text(23).starts_with("FOOTER"), "footer must not scroll"); } #[test] fn subregion_scroll_does_not_feed_scrollback() { // Lines scrolled out of a program-managed sub-region are not terminal history. let mut t = Terminal::new(10, 20); t.advance(b"\x1b[3;6r\x1b[3;1H"); for _ in 0..20 { t.advance(b"x\n"); } assert_eq!(t.screen().scrollback().len(), 0); } #[test] fn origin_mode_makes_cup_relative_to_the_region() { let mut t = Terminal::new(24, 20); t.advance(b"\x1b[5;20r"); // region rows 4..19 (0-based) t.advance(b"\x1b[?6h"); // DECOM on t.advance(b"\x1b[1;1H"); // "home" assert_eq!(t.cursor().row, 4, "origin mode: home is the region top"); } #[test] fn alternate_screen_has_no_scrollback() { let mut t = Terminal::new(5, 20); t.advance(b"\x1b[?1049h"); for _ in 0..50 { t.advance(b"x\n"); } assert_eq!(t.screen().scrollback().len(), 0); } #[test] fn cursor_movement_damages_both_rows() { // Forgetting this leaves a ghost cursor block behind. let mut t = Terminal::new(10, 20); t.clear_damage(); t.advance(b"\x1b[5;5H"); let dirty: Vec<usize> = t.damage().iter_dirty().collect(); assert!(dirty.contains(&0), "the old cursor row must be damaged"); assert!(dirty.contains(&4), "the new cursor row must be damaged"); } }
Challenge Extensions
- Move
Gridto a ring of lines soscroll_up(1)is O(cols). Benchmarkcatting a 100 MB file before and after. Report the numbers. - Implement reflow, cursor tracking included. Write the cursor test first.
- Trim trailing blanks on lines entering scrollback and measure the memory saving on a real session.
- Cell-level damage instead of row-level. Measure whether it actually helps — it usually does not at terminal sizes, and finding that out is the lesson.
- Implement
DECALN(ESC # 8, fill the screen withE), the classic terminal alignment test. It exercises full-screen write in one sequence.
Validation / Self-check
- Draw the
Terminalstructure with every field, and say what each one is for. - State the pending-wrap rule, and name every event that clears the flag.
- What visible bug appears without pending wrap? Give the exact sequence that shows it.
- Why does each
Lineneed awrappedflag? Name two consumers. - What does DECSTBM do, what does setting it do to the cursor, and what are its parameter bases?
- Why do lines scrolled out of a sub-region not enter scrollback?
- What does origin mode change, and which sequences does it affect?
- Name the three resize policies and one hard problem with each.
- Why is the alternate screen never reflowed?
- Why does a cursor move damage two rows?
- Compute the scrollback memory for 10,000 lines × 200 columns × 16-byte cells. Name two mitigations.
- Which single function must all
(row, col)access go through, and why?
Next: The CSI Catalog.