From 66d99c043fab2f574fc2df182267fa5ac8e69d54 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:54:28 +0800 Subject: [PATCH 1/4] feat(core): implement DECSTBM scroll regions refs #29 The parser accepts CSI r, line feeds scroll inside the margins without touching history, and IL/DL stop at the region bottom. --- experiments/core/edit_csi_test.cpp | 10 +++++++ experiments/core/screen.hpp | 45 ++++++++++++++++++++++++------ experiments/core/terminal.hpp | 6 +++- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/experiments/core/edit_csi_test.cpp b/experiments/core/edit_csi_test.cpp index ff7ee037..f47e9a7a 100644 --- a/experiments/core/edit_csi_test.cpp +++ b/experiments/core/edit_csi_test.cpp @@ -64,6 +64,16 @@ int main() { require(row_text(lines.screen, 0).empty() && row_text(lines.screen, 1).empty() && row_text(lines.screen, 2).empty() && row_text(lines.screen, 3).empty(), "over-large IL should clear the screen"); + // DECSTBM: rows 2..3 (1-based) scroll on their own; other rows stay put. + Parser region(4, 10, 10, termforge::ParseMode::recovery); + region.feed("a\r\nb\r\nc\r\nd"); + region.feed("\x1b[2;3r"); + require(region.screen.row == 1 && region.screen.column == 0, "DECSTBM did not home the cursor"); + region.feed("\n\n"); + require(row_text(region.screen, 0) == "a" && row_text(region.screen, 1) == "c" && + row_text(region.screen, 2).empty() && row_text(region.screen, 3) == "d", + "region scroll changed rows outside the margins"); + require(region.screen.row == 2, "region scroll moved the cursor past the margin"); std::puts("edit csi contracts passed"); return 0; } diff --git a/experiments/core/screen.hpp b/experiments/core/screen.hpp index affaad39..ccb85e51 100644 --- a/experiments/core/screen.hpp +++ b/experiments/core/screen.hpp @@ -81,6 +81,10 @@ class Screen { std::deque history; int columns_; uint64_t next_id_ = 1; + int scroll_top_ = 0; + // Negative means "the bottom row", which keeps full-screen scrolling on the + // history path after a resize changes the height. + int scroll_bottom_ = -1; uint64_t buffer_epoch_ = 0; uint64_t order_revision_ = 0; bool ordered_ = true; @@ -384,6 +388,19 @@ class Screen { row = std::clamp(r, 0, height() - 1); column = std::clamp(c, 0, width() - 1); wrap_pending = false; invalidate_attachment(); } + int scroll_bottom() const { return scroll_bottom_ < 0 ? height() - 1 : scroll_bottom_; } + // DECSTBM: rows inside [top, bottom] scroll together. The list itself keeps + // whole rows only, so a partial region scroll shifts entries in place; rows + // that leave a full-screen region still reach history. + void set_scroll_region(int top, int bottom) { + if (top < 0) top = 0; + if (bottom < 0 || bottom >= height()) bottom = height() - 1; + if (bottom - top < 1) throw InputError("invalid scroll region"); + scroll_top_ = top; scroll_bottom_ = bottom; + // DECSTBM homes the cursor. xterm places it at the region's top-left; + // the original application's choice is not observed yet. + move(top, 0); + } void newline(bool automatic = false, size_t used_columns = 0) { auto& previous = lines[row]; changed(previous); @@ -392,7 +409,17 @@ class Screen { // A newly allocated bottom row is known before moving its predecessor. previous.next_id = automatic ? (row + 1 < height() ? lines[row + 1].id : next_id_) : 0; wrap_pending = false; invalidate_attachment(); - if (row + 1 < height()) { ++row; return; } + if (row + 1 < height() && row < scroll_bottom()) { ++row; return; } + if (row < scroll_bottom() || scroll_top_ > 0 || scroll_bottom() < height() - 1) { + // Scrolling inside a region (or inside a movement that stops early) + // moves rows up within the margins and blanks the last region row. + for (int r = scroll_top_; r < scroll_bottom(); ++r) lines[r] = std::move(lines[r + 1]); + lines[scroll_bottom()] = make_line(width(), Cell{}, next_id_); + row = scroll_bottom(); + advance_order(ordered_); + return; + } + if (row < height() - 1) { ++row; return; } const auto limit = active_history_limit(); if (limit) { history.push_back(std::move(lines.front())); @@ -440,21 +467,23 @@ class Screen { } void insert_lines(int count) { if (count < 1) throw InputError("invalid insert line count"); - const int insert = (std::min)(count, height() - row); + const int bottom = scroll_bottom(); + const int insert = (std::min)(count, bottom + 1 - row); if (insert <= 0) { wrap_pending = false; return; } - // Rows pushed past the bottom leave the screen; their identities stay - // spent. Inserted rows are new lines and get fresh identities. - for (int r = height() - 1; r >= row + insert; --r) lines[r] = std::move(lines[r - insert]); + // Rows pushed past the bottom margin leave the screen; their identities + // stay spent. Inserted rows are new lines and get fresh identities. + for (int r = bottom; r >= row + insert; --r) lines[r] = std::move(lines[r - insert]); for (int r = row; r < row + insert; ++r) lines[r] = make_line(width(), Cell{}, next_id_); wrap_pending = false; invalidate_attachment(); } void delete_lines(int count) { if (count < 1) throw InputError("invalid delete line count"); - const int remove = (std::min)(count, height() - row); + const int bottom = scroll_bottom(); + const int remove = (std::min)(count, bottom + 1 - row); if (remove <= 0) { wrap_pending = false; return; } - for (int r = row; r < height() - remove; ++r) lines[r] = std::move(lines[r + remove]); - for (int r = height() - remove; r < height(); ++r) lines[r] = make_line(width(), Cell{}, next_id_); + for (int r = row; r <= bottom - remove; ++r) lines[r] = std::move(lines[r + remove]); + for (int r = bottom - remove + 1; r <= bottom; ++r) lines[r] = make_line(width(), Cell{}, next_id_); wrap_pending = false; invalidate_attachment(); } diff --git a/experiments/core/terminal.hpp b/experiments/core/terminal.hpp index c0f523d0..137d5cdd 100644 --- a/experiments/core/terminal.hpp +++ b/experiments/core/terminal.hpp @@ -59,7 +59,7 @@ class Parser { return; } if (final == 'm') { screen.sgr(params); return; } - if (params.size() > ((final == 'H' || final == 'f') ? 2u : 1u)) + if (params.size() > ((final == 'H' || final == 'f' || final == 'r') ? 2u : 1u)) throw InputError("too many CSI parameters"); const int n = params[0] == 0 ? 1 : params[0]; switch (final) { @@ -73,6 +73,10 @@ class Parser { case 'J': screen.erase_display(params[0]); break; case 'K': screen.erase_line(params[0]); break; case 'L': screen.insert_lines(n); break; + case 'r': + screen.set_scroll_region(params[0] == 0 ? 0 : params[0] - 1, + params.size() < 2 || params[1] == 0 ? -1 : params[1] - 1); + break; case 'M': screen.delete_lines(n); break; case 'X': screen.erase_characters(n); break; case '@': screen.insert_characters(n); break; From 1ef47c322a8b737caec09db1dd3574fd2a7a1697 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:04:03 +0800 Subject: [PATCH 2/4] test(differential): treat the scroll region as a supported capability refs #29 DECSTBM is implemented, so the matrix compares those records and the expected counts assume every plan now yields a real comparison. --- experiments/differential/capability_matrix.py | 4 +++- experiments/differential/test_capability_matrix.py | 13 +++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/experiments/differential/capability_matrix.py b/experiments/differential/capability_matrix.py index 8f1572f0..5c853c82 100644 --- a/experiments/differential/capability_matrix.py +++ b/experiments/differential/capability_matrix.py @@ -21,7 +21,9 @@ def data(self): CAPABILITIES = ( dict(name="alternate_screen", supported=True, case=Case("alternate_screen", "A\r\nB\x1b[?1049hALT\x1b[?1049lZ")), - dict(name="scroll_region", supported=False, + # DECSTBM is implemented now, so the scroll-region case is compared instead + # of being written off as an unsupported capability. + dict(name="scroll_region", supported=True, case=Case("scroll_region", "A\r\nB\r\nC\r\nD\x1b[2;3r\x1b[3;1H\nX\x1b[r\x1b[1;1H")), # The product core answers CSI 6n now, so this is a supported capability and # its records are compared instead of being written off as unsupported. diff --git a/experiments/differential/test_capability_matrix.py b/experiments/differential/test_capability_matrix.py index 8871d4ab..8ae4bd5c 100644 --- a/experiments/differential/test_capability_matrix.py +++ b/experiments/differential/test_capability_matrix.py @@ -31,18 +31,15 @@ def records(self, name): def test_extended_matrix_preserves_execution_and_support_boundaries(self): self.assertTrue(self.summary["execution_passed"], self.summary["counts"]) self.assertEqual(self.summary["runs"], 25) - self.assertEqual(self.summary["counts"], {"core_unsupported": 5, "match": 11, + self.assertEqual(self.summary["counts"], {"core_unsupported": 0, "match": 16, "reference_difference": 4, "resize_difference": 5}) self.assertFalse(self.summary["semantic_acceptance"]) self.assertTrue(self.summary["resize_covered"]) self.assertFalse(self.summary["real_application_covered"]) - for name in ("scroll_region",): - for record in self.records(name): - self.assertEqual(record["status"], "core_unsupported") - self.assertFalse(record["expected_core_support"]) - self.assertIn("trace failed after checkpoint", - (self.artifacts / "suite" / name / record["plan"].replace(":", "-") / - "core.stderr.txt").read_text()) + for record in self.records("scroll_region"): + # DECSTBM is implemented, so these records carry a real comparison. + self.assertTrue(record["expected_core_support"]) + self.assertNotEqual(record["status"], "core_unsupported") for record in self.records("cursor_query"): # The core answers the cursor report now, so this is a compared case. self.assertTrue(record["expected_core_support"]) From 9d92d7ab5468fe462be81067a7a7167b96d1064b Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:07:41 +0800 Subject: [PATCH 3/4] fix(core): home DECSTBM on the screen origin like the reference refs #29 The reference terminal calls goto(0,0) after setting the margins and ignores an invalid region; matching it removes the two differential differences and keeps the region test honest. --- experiments/core/edit_csi_test.cpp | 5 +++-- experiments/core/screen.hpp | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/experiments/core/edit_csi_test.cpp b/experiments/core/edit_csi_test.cpp index f47e9a7a..840ff58b 100644 --- a/experiments/core/edit_csi_test.cpp +++ b/experiments/core/edit_csi_test.cpp @@ -68,8 +68,9 @@ int main() { Parser region(4, 10, 10, termforge::ParseMode::recovery); region.feed("a\r\nb\r\nc\r\nd"); region.feed("\x1b[2;3r"); - require(region.screen.row == 1 && region.screen.column == 0, "DECSTBM did not home the cursor"); - region.feed("\n\n"); + require(region.screen.row == 0 && region.screen.column == 0, "DECSTBM did not home the cursor"); + // Two feeds only walk to the bottom margin; the third scrolls the region. + region.feed("\n\n\n"); require(row_text(region.screen, 0) == "a" && row_text(region.screen, 1) == "c" && row_text(region.screen, 2).empty() && row_text(region.screen, 3) == "d", "region scroll changed rows outside the margins"); diff --git a/experiments/core/screen.hpp b/experiments/core/screen.hpp index ccb85e51..827a5cc8 100644 --- a/experiments/core/screen.hpp +++ b/experiments/core/screen.hpp @@ -395,11 +395,13 @@ class Screen { void set_scroll_region(int top, int bottom) { if (top < 0) top = 0; if (bottom < 0 || bottom >= height()) bottom = height() - 1; - if (bottom - top < 1) throw InputError("invalid scroll region"); + // An invalid region is ignored rather than rejected: the reference + // terminal logs and returns, leaving the previous margins in place. + if (bottom - top < 1) return; scroll_top_ = top; scroll_bottom_ = bottom; - // DECSTBM homes the cursor. xterm places it at the region's top-left; - // the original application's choice is not observed yet. - move(top, 0); + // DECSTBM homes the cursor to the screen origin, matching the reference + // terminal (vendor/alacritty_terminal/src/term/mod.rs: goto(0, 0)). + move(0, 0); } void newline(bool automatic = false, size_t used_columns = 0) { auto& previous = lines[row]; From c175666d16343a7eb3efc2669097ed1f21695734 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:15:33 +0800 Subject: [PATCH 4/4] test(differential): drop the empty unsupported bucket from the expected counts refs #29 With every capability compared the summary omits core_unsupported, so the expectation lists only the three observed buckets. --- experiments/differential/test_capability_matrix.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/experiments/differential/test_capability_matrix.py b/experiments/differential/test_capability_matrix.py index 8ae4bd5c..6e2e9825 100644 --- a/experiments/differential/test_capability_matrix.py +++ b/experiments/differential/test_capability_matrix.py @@ -31,8 +31,10 @@ def records(self, name): def test_extended_matrix_preserves_execution_and_support_boundaries(self): self.assertTrue(self.summary["execution_passed"], self.summary["counts"]) self.assertEqual(self.summary["runs"], 25) - self.assertEqual(self.summary["counts"], {"core_unsupported": 0, "match": 16, - "reference_difference": 4, "resize_difference": 5}) + # Every plan yields a real comparison now, so the summary omits the + # core_unsupported bucket entirely. + self.assertEqual(self.summary["counts"], {"match": 16, "reference_difference": 4, + "resize_difference": 5}) self.assertFalse(self.summary["semantic_acceptance"]) self.assertTrue(self.summary["resize_covered"]) self.assertFalse(self.summary["real_application_covered"])