Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions experiments/core/edit_csi_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ 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 == 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");
require(region.screen.row == 2, "region scroll moved the cursor past the margin");
std::puts("edit csi contracts passed");
return 0;
}
47 changes: 39 additions & 8 deletions experiments/core/screen.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ class Screen {
std::deque<Line> 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;
Expand Down Expand Up @@ -384,6 +388,21 @@ 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;
// 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 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];
changed(previous);
Expand All @@ -392,7 +411,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()));
Expand Down Expand Up @@ -440,21 +469,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();
}
Expand Down
6 changes: 5 additions & 1 deletion experiments/core/terminal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion experiments/differential/capability_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 8 additions & 9 deletions experiments/differential/test_capability_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,17 @@ 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,
"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"])
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"])
Expand Down
Loading