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:

ConsumerWhy it needs wrapped
Copy/pasteA 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 reflowYou can only reflow lines that were wrapped
SearchA 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

EventEffect on pending_wrap
Write a char into the last columnSet
Write a char anywhere elseClear
A printable char arrives while setWrap first (mark the line wrapped), then write, then clear
LF, CR, BS, HTClear (any explicit cursor motion clears it)
Any explicit cursor positioning (CUP, CUF, CUB, …)Clear
DECAWM (?7) is resetNo wrapping at all; the cursor sticks in the last column and overwrites
ResizeClear (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.

RuleDetail
Parameters are 1-based in the sequence, and you store them 0-basedOff-by-one heaven; convert in exactly one place
CSI r with no paramsReset to the full screen
Setting DECSTBM homes the cursorTo (0,0), or to the region top if origin mode is set. Easy to forget.
Bottom must be > topOtherwise ignore the whole sequence
LF at the bottom margin scrolls the region, not the screenAnd only lines within the region move
Only the primary screen's scroll-out goes to scrollbackAnd 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

OperationSequenceEffect
Index / linefeedLF, IND (ESC D)Cursor down; if at the region bottom, scroll the region up by 1
Reverse indexRI (ESC M)Cursor up; if at the region top, scroll the region down by 1
Scroll upSU (CSI Ps S)Scroll the region up by Ps, cursor unmoved
Scroll downSD (CSI Ps T)Scroll the region down by Ps, cursor unmoved
Insert lineIL (CSI Ps L)Insert Ps blank lines at the cursor; lines below shift down within the region
Delete lineDL (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

PropertyValue
StorageVecDeque<Line> — push at the back, evict from the front. Never Vec with remove(0).
LimitConfigurable; 10,000 lines is a common default. Memory is limit × cols × sizeof(Cell).
Alternate screenNone. Full stop.
Sub-region scrollsDo not contribute
Cleared byCSI 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:

OperationDamage
Write a cellThat row
Cursor moveThe old row and the new row (the cursor is drawn, so both must repaint)
ScrollThe whole region
EraseThe affected rows
Mode change that affects display (alt screen, ?25 cursor visibility)All
ResizeAll

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

  1. Move Grid to a ring of lines so scroll_up(1) is O(cols). Benchmark catting a 100 MB file before and after. Report the numbers.
  2. Implement reflow, cursor tracking included. Write the cursor test first.
  3. Trim trailing blanks on lines entering scrollback and measure the memory saving on a real session.
  4. 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.
  5. Implement DECALN (ESC # 8, fill the screen with E), the classic terminal alignment test. It exercises full-screen write in one sequence.

Validation / Self-check

  1. Draw the Terminal structure with every field, and say what each one is for.
  2. State the pending-wrap rule, and name every event that clears the flag.
  3. What visible bug appears without pending wrap? Give the exact sequence that shows it.
  4. Why does each Line need a wrapped flag? Name two consumers.
  5. What does DECSTBM do, what does setting it do to the cursor, and what are its parameter bases?
  6. Why do lines scrolled out of a sub-region not enter scrollback?
  7. What does origin mode change, and which sequences does it affect?
  8. Name the three resize policies and one hard problem with each.
  9. Why is the alternate screen never reflowed?
  10. Why does a cursor move damage two rows?
  11. Compute the scrollback memory for 10,000 lines × 200 columns × 16-byte cells. Name two mitigations.
  12. Which single function must all (row, col) access go through, and why?

Next: The CSI Catalog.