From 7393d706be20d814a48545b7951222a23859029e Mon Sep 17 00:00:00 2001 From: Scott Mohekey Date: Thu, 30 Jul 2026 02:40:53 +1200 Subject: [PATCH] fix: scroll a partial DECSTBM region within itself, not below it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the cursor is on the bottom margin and a line feed occurs, `index()` grew the scrollback (inserting a line just below the last visible row) whenever `marginTop == 0`. That's only correct for a *full-height* scroll — a region whose bottom is the last row of the screen. For a *partial* region (top margin 0 but bottom margin above the last row) it inserted a line *below* the region, shoving everything underneath out of place instead of scrolling the region's own rows. Gate the scrollback-growth path on the region also reaching the last row (`marginBottom == viewHeight - 1`); otherwise scroll within the region. Applications that redraw part of an inline UI with partial scroll regions (e.g. ratatui's inline viewport, used by the Codex CLI) were getting scrambled output. --- lib/src/core/buffer/buffer.dart | 7 ++++- test/src/core/buffer/scroll_region_test.dart | 33 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 test/src/core/buffer/scroll_region_test.dart diff --git a/lib/src/core/buffer/buffer.dart b/lib/src/core/buffer/buffer.dart index 10789382..5327c868 100644 --- a/lib/src/core/buffer/buffer.dart +++ b/lib/src/core/buffer/buffer.dart @@ -235,7 +235,12 @@ class Buffer { void index() { if (isInVerticalMargin) { if (_cursorY == _marginBottom) { - if (marginTop == 0 && !isAltBuffer) { + // Growing the scrollback (insert a line just below the last visible row, so the top line is + // preserved as history) is only correct for a *full-height* scroll — a region whose bottom is + // the last row of the screen. codex/ratatui set *partial* scroll regions (e.g. `ESC[1;9r`, + // `ESC[10;35r`) to redraw part of its inline UI; scrolling one of those must shift lines *within* + // the region (`scrollUp`), not shove everything below it down — doing so scrambled the display. + if (marginTop == 0 && _marginBottom == viewHeight - 1 && !isAltBuffer) { lines.insert(absoluteMarginBottom + 1, _newEmptyLine()); } else { scrollUp(1); diff --git a/test/src/core/buffer/scroll_region_test.dart b/test/src/core/buffer/scroll_region_test.dart new file mode 100644 index 00000000..e564d8b9 --- /dev/null +++ b/test/src/core/buffer/scroll_region_test.dart @@ -0,0 +1,33 @@ +import 'package:test/test.dart'; +import 'package:xterm/core.dart'; + +void main() { + group('partial DECSTBM scroll region', () { + test('scrolling a partial region does not disturb rows below it', () { + final terminal = Terminal(); + terminal.resize(10, 6); + + // Fill six rows with identifiable content. + terminal.write('L0\r\nL1\r\nL2\r\nL3\r\nL4\r\nL5'); + + // A *partial* scroll region covering rows 1..3 (indices 0..2); the screen is 6 rows tall. + terminal.write('\x1b[1;3r'); + // Park the cursor on the bottom margin (row 3) and index — scrolls the region up by one. + terminal.write('\x1b[3;1H'); + terminal.write('\n'); + + String row(int i) => terminal.buffer.lines[i].toString().trim(); + + // Rows below the region must be untouched — the bug inserted a line below the region and shoved + // these down. + expect(row(3), 'L3'); + expect(row(4), 'L4'); + expect(row(5), 'L5'); + + // The region itself scrolled up: L0 fell off the top, L1/L2 moved up, the bottom row cleared. + expect(row(0), 'L1'); + expect(row(1), 'L2'); + expect(row(2), ''); + }); + }); +}