Lab 10: Scroll Regions, the Alternate Screen, and Insert/Delete

Background

This lab is what makes vim, less, top, and man work. Three features:

  1. Scroll regions (DECSTBM) — so a status line can stay put while text scrolls.
  2. The alternate screen (?1049) — so a full-screen program can take over and give the screen back exactly as it found it.
  3. Insert/delete operations (ICH/DCH/IL/DL/SU/SD) — so a program can edit the screen without redrawing everything.

Together they are the difference between a terminal that runs echo and a terminal that runs software.

Why This Lab Matters

  • Every full-screen program uses all three, and the failures are dramatic and obvious.
  • The interactions (insert/delete within a scroll region, alt screen with a scroll region) are where the remaining bugs live after the individual features work.

Prerequisites


Predict First

  1. CSI 5;20r, then 30 lines of output. What is in scrollback?
  2. Enter the alt screen, output 100 lines, leave. What is in scrollback?
  3. CSI 3;6r, cursor at row 0 (outside the region), then CSI 1L. What happens?
  4. CSI 5;20r then CSI 999;1H. Where is the cursor, with and without origin mode?

Step 1: The Scroll Region

#![allow(unused)]
fn main() {
fn set_scroll_region(&mut self, params: &Params) {
    // Parameters are 1-based and inclusive; storage is 0-based and inclusive.
    // Convert in exactly ONE place or you will fight off-by-ones forever.
    let top = params.get_or(0, 1).max(1) as usize - 1;
    let bottom = params.get_or(1, self.rows as u16) as usize - 1;

    // An invalid region is IGNORED entirely — the previous region survives.
    // Silently clamping instead produces a region the program did not ask for.
    if top >= bottom || bottom >= self.rows { return; }

    self.scroll_top = top;
    self.scroll_bottom = bottom;

    // DECSTBM HOMES THE CURSOR. Easy to miss; programs depend on it.
    self.cursor.row = if self.modes.contains(Mode::ORIGIN) { top } else { 0 };
    self.cursor.col = 0;
    self.cursor.pending_wrap = false;
}

fn line_feed(&mut self) {
    if self.cursor.row == self.scroll_bottom {
        // At the region bottom: scroll the REGION, not the screen.
        self.scroll_region_up(1);
    } else if self.cursor.row + 1 < self.rows {
        self.cursor.row += 1;
    }
    // Note: a cursor BELOW the region bottom (possible without origin mode)
    // moves down normally until it hits the last row. It does not scroll.
}

fn reverse_index(&mut self) {          // ESC M
    if self.cursor.row == self.scroll_top {
        self.scroll_region_down(1);
    } else if self.cursor.row > 0 {
        self.cursor.row -= 1;
    }
}

fn scroll_region_up(&mut self, n: usize) {
    let full_screen = self.scroll_top == 0 && self.scroll_bottom == self.rows - 1;
    let evicted = self.screen.active_grid_mut()
        .scroll_region_up(self.scroll_top, self.scroll_bottom, n);

    // Scrollback gets lines ONLY from a full-screen scroll on the PRIMARY buffer.
    // A program-managed sub-region's discarded lines are not terminal history —
    // which is why you cannot scroll back through vim's buffer.
    if full_screen && self.screen.active == BufferKind::Primary {
        for line in evicted {
            self.screen.scrollback.push_back(line.trimmed());   // trim trailing blanks
            if self.screen.scrollback.len() > self.scrollback_limit {
                self.screen.scrollback.pop_front();
            }
        }
    }
    self.damage.mark_range(self.scroll_top, self.scroll_bottom);
}
}

Step 2: The Alternate Screen

#![allow(unused)]
fn main() {
fn set_alt_screen(&mut self, enable: bool, save_cursor: bool, clear: bool) {
    if enable == (self.screen.active == BufferKind::Alternate) { return; }  // idempotent

    if enable {
        if save_cursor { self.saved_cursor = Some(self.cursor.snapshot()); }
        self.screen.active = BufferKind::Alternate;
        if clear {
            // ?1049h clears so the program starts from a known state.
            self.screen.alternate.clear_all(Cell::blank(Style::default()));
        }
    } else {
        self.screen.active = BufferKind::Primary;
        if save_cursor {
            if let Some(c) = self.saved_cursor.take() { self.cursor.restore(c); }
        }
    }
    // The scroll region is per-terminal, not per-buffer, and switching buffers
    // resets it. (xterm's behavior; programs set their own region on entry.)
    self.scroll_top = 0;
    self.scroll_bottom = self.rows - 1;
    self.cursor.pending_wrap = false;
    self.damage.mark_all();
}

fn set_dec_modes(&mut self, params: &Params, set: bool) {
    for i in 0..params.len() {
        match params.get_or(i, 0) {
            47   => self.set_alt_screen(set, false, false),      // legacy
            1047 => self.set_alt_screen(set, false, true),
            1048 => if set { self.saved_cursor = Some(self.cursor.snapshot()) }
                    else if let Some(c) = self.saved_cursor.take() { self.cursor.restore(c) },
            1049 => self.set_alt_screen(set, true, set),          // the useful one
            // ... the rest of the modes
            n => self.set_simple_mode(n, set),
        }
    }
}
}

Then enforce "no scrollback on the alternate screen" structurally, not by remembering:

#![allow(unused)]
fn main() {
impl Screen {
    /// Scrollback belongs to the PRIMARY buffer alone. Returning None for the
    /// alternate makes the rule impossible to violate by forgetting it.
    pub fn scrollback_mut(&mut self) -> Option<&mut VecDeque<Line>> {
        match self.active {
            BufferKind::Primary => Some(&mut self.scrollback),
            BufferKind::Alternate => None,
        }
    }
}
}

Step 3: Insert and Delete

All six operate within the scroll region, and IL/DL do nothing when the cursor is outside it.

#![allow(unused)]
fn main() {
fn insert_lines(&mut self, n: usize) {
    // Outside the region: a no-op. Programs rely on this.
    if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom { return; }
    let n = n.min(self.scroll_bottom - self.cursor.row + 1);
    // Shift [cursor.row, scroll_bottom] down by n, filling with blanks.
    self.screen.active_grid_mut()
        .scroll_region_down_from(self.cursor.row, self.scroll_bottom, n,
                                 Cell::blank(self.cursor.style.erase_style()));
    self.damage.mark_range(self.cursor.row, self.scroll_bottom);
    // xterm leaves the column unchanged; the DEC spec is ambiguous. Follow
    // xterm — that is what programs are written against.
}

fn insert_chars(&mut self, n: usize) {
    let n = n.min(self.cols - self.cursor.col);
    let line = self.screen.active_grid_mut().line_mut(self.cursor.row);
    // Shift right within the line; characters pushed past the end are LOST.
    line.cells.copy_within(self.cursor.col..self.cols - n, self.cursor.col + n);
    for c in self.cursor.col..self.cursor.col + n {
        line.clear_cell(c, Cell::blank(self.cursor.style.erase_style()));
    }
    self.damage.mark(self.cursor.row);
}

fn delete_chars(&mut self, n: usize) {
    let n = n.min(self.cols - self.cursor.col);
    let line = self.screen.active_grid_mut().line_mut(self.cursor.row);
    line.cells.copy_within(self.cursor.col + n..self.cols, self.cursor.col);
    for c in self.cols - n..self.cols {
        line.clear_cell(c, Cell::blank(self.cursor.style.erase_style()));
    }
    self.damage.mark(self.cursor.row);
}
}

Warning: copy_within on a line containing wide characters can split a wide/spacer pair at the shift boundary. After any shift, sweep the affected range and repair orphans — or route through clear_cell on the boundary cells. The property test from Lab 9 will find this if you skip it.


Expected Output

$ mini-term run --rows 10 --cols 20 --format debug -- bash -c '
    printf "\033[H\033[2J"
    printf "HEADER\n"
    printf "\033[3;8r"          # region rows 3-8 (1-based)
    printf "\033[10;1HFOOTER"
    printf "\033[3;1H"
    for i in $(seq 1 20); do printf "line %d\n" $i; done
'
--- screen ---
 0 | HEADER
 1 |
 2 | line 15
 3 | line 16
 4 | line 17
 5 | line 18
 6 | line 19
 7 | line 20
 8 |
 9 | FOOTER
scrollback: 0 lines        ← sub-region scrolls do NOT feed scrollback

Debugging Steps

top's header scrolls away

Either DECSTBM is not implemented, or line_feed scrolls the screen rather than the region.

vim quits and leaves its buffer in your scrollback

The alternate screen is feeding scrollback. Make scrollback_mut() return None for it.

vim quits and the shell prompt is in the wrong place

?1049l is not restoring the saved cursor.

less scrolls the wrong region by one line

Off-by-one in the 1-based→0-based conversion, or scroll_bottom treated as exclusive. Both are the same bug class; convert in one place and add a test at each boundary.

IL inside a region corrupts rows outside it

Your shift range is [cursor.row, rows-1] instead of [cursor.row, scroll_bottom].

Everything works until the program sets a region and uses the alt screen

The region is not reset on buffer switch, so the alternate screen inherits the primary's margins.


Experiment

CLAIM. The alternate screen is why full-screen programs "give your terminal back," and it is directly observable.

METHOD.

# 1. Fill your scrollback with something recognizable.
for i in $(seq 1 50); do echo "scrollback line $i"; done

# 2. Run a full-screen program.
less /etc/services
#    scroll around, then q

# 3. Scroll up in your terminal. Your 50 lines are intact; less's content is
#    nowhere. That is the alternate screen.

# 4. Now defeat it:
less --no-init /etc/services      # -X: do NOT use the alternate screen
#    q, then scroll up: less's content is now IN your scrollback.

# 5. Watch the sequences:
pty-runner --record less.cast -- less /etc/services
grep -o 'u001b\[?1049[hl]' less.cast

PREDICTION. Before step 4: what will your scrollback contain after less --no-init? Which is the better default, and why do you think -X exists?


Test

#![allow(unused)]
fn main() {
#[test]
fn scroll_region_confines_scrolling() {
    let mut t = Terminal::new(10, 20);
    t.advance(b"\x1b[HHEADER");
    t.advance(b"\x1b[3;8r");
    t.advance(b"\x1b[10;1HFOOTER");
    t.advance(b"\x1b[3;1H");
    for i in 1..=20 { t.advance(format!("line {i}\n").as_bytes()); }
    assert!(t.snapshot_text().lines().next().unwrap().starts_with("HEADER"));
    assert!(t.snapshot_text().lines().nth(9).unwrap().starts_with("FOOTER"));
}

#[test]
fn subregion_scrolls_do_not_feed_scrollback() {
    let mut t = Terminal::new(10, 20);
    t.advance(b"\x1b[3;8r\x1b[3;1H");
    for _ in 0..50 { t.advance(b"x\n"); }
    assert_eq!(t.screen().scrollback().len(), 0);
}

#[test]
fn full_screen_scrolls_do_feed_scrollback() {
    let mut t = Terminal::new(10, 20);
    for i in 0..50 { t.advance(format!("line{i}\n").as_bytes()); }
    assert!(t.screen().scrollback().len() >= 40);
}

#[test]
fn alt_screen_round_trip_is_exact() {
    let mut t = Terminal::new(5, 20);
    t.advance(b"before\n\x1b[3;10H");
    let cursor_before = t.cursor();
    let screen_before = t.snapshot_text();

    t.advance(b"\x1b[?1049h");
    t.advance(b"alt content\n\x1b[1;1H");
    t.advance(b"\x1b[?1049l");

    assert_eq!(t.snapshot_text(), screen_before);
    assert_eq!((t.cursor().row, t.cursor().col), (cursor_before.row, cursor_before.col));
}

#[test]
fn alt_screen_resets_the_scroll_region() {
    let mut t = Terminal::new(10, 20);
    t.advance(b"\x1b[3;8r");
    t.advance(b"\x1b[?1049h");
    assert_eq!((t.scroll_top(), t.scroll_bottom()), (0, 9),
               "the alternate screen must not inherit the primary's margins");
}

#[test]
fn il_outside_the_region_is_a_noop() {
    let mut t = Terminal::new(10, 20);
    t.advance(b"\x1b[3;8r");
    t.advance(b"\x1b[1;1HTOP\x1b[1;1H\x1b[5L");
    assert!(t.snapshot_text().lines().next().unwrap().starts_with("TOP"));
}

#[test]
fn dch_shifts_left_and_fills_from_the_right() {
    let mut t = Terminal::new(3, 10);
    t.advance(b"abcdefghij\x1b[1;3H\x1b[2P");
    assert_eq!(t.snapshot_text().lines().next().unwrap(), "abefghij");
}

#[test]
fn insert_delete_preserve_the_wide_char_invariant() {
    let mut t = Terminal::new(3, 10);
    t.advance("日本語abc".as_bytes());
    t.advance(b"\x1b[1;2H\x1b[1P");     // delete a char starting on a spacer
    assert_spacer_invariant(&t);
}
}

Challenge Extensions

  1. Implement ?1047 and ?47 correctly and write the test that distinguishes all three variants.
  2. Add a "scroll region" indicator to --format debug showing the margins as ├/┤ markers in the row gutter. It makes region bugs visible at a glance.
  3. Implement left/right margins (DECLRMM ?69 + DECSLRM). Rare but real, and it changes what CSI s means — a good exercise in mode-dependent parsing.
  4. Measure the cost of sub-region scrolls with a benchmark, and decide whether the ring optimization should apply to sub-regions too.
  5. Run the interactive matrix: vim, less, top, htop, man, nano, and tmux, each for 30 seconds, recorded. Diff your snapshots against xterm's behavior and log every difference.

Deliverables

  • DECSTBM with 1-based conversion in one place, cursor homing, and invalid regions ignored.
  • Scroll region honored by LF, RI, SU, SD, IL, and DL.
  • Sub-region scrolls excluded from scrollback, enforced structurally.
  • ?1049, ?1047, ?47, and ?1048 implemented and distinguished.
  • No scrollback on the alternate screen, enforced by the type, not by memory.
  • ICH/DCH/ECH/IL/DL/SU/SD, all preserving the wide-character invariant.
  • vim, less, and top render correctly under mini-term.
  • Golden cases for all three added to the corpus.

Validation / Self-check

  1. What does DECSTBM do to the cursor, and what are the parameter bases?
  2. Which scrolls feed scrollback, and why is the rule what it is?
  3. What exactly do ?1049h and ?1049l do, in order?
  4. Why does the alternate screen have no scrollback? Enforce it structurally rather than by convention — how?
  5. What happens to the scroll region when the buffer switches, and why does it matter?
  6. What does IL do when the cursor is outside the region?
  7. Distinguish ECH, DCH, and EL 0.
  8. Why can copy_within break the wide-character invariant, and how do you repair it?
  9. What does less -X do, and what does it demonstrate?
  10. A user reports "vim leaves junk in my scrollback." Name the two possible causes.

Next: Lab 11 — OSC, Modes, and Mouse Reporting.