fix(terminal): guard wrap count when resize pushes cursor to scrollback

In the column-shrink (.lt) branch of PageList.resize, resizeWithoutReflow
lowers self.rows before resizeCols runs. Because the active area is anchored
to the bottom, shrinking rows moves the active-area top down; a cursor near
the top of the old active area then ends up above the new active area (in
scrollback).

resizeCols counts wrap continuations from the cursor pin up to the active-area
top via a .left_up rowIterator. When the cursor pin is above the limit, the
range is reversed and the iterator's order assertion fires (SIGABRT in debug;
silently iterates empty in release).

Count zero wraps when the cursor pin is above the active area, mirroring the
post-reflow preserved-cursor block which already no-ops for a non-active
cursor. Add a regression test.
This commit is contained in:
Claude Opus 4.8
2026-06-05 21:03:11 +02:00
committed by Lukas
parent 42fcd58dba
commit 7837563ed6

View File

@@ -1047,6 +1047,16 @@ fn resizeCols(
const wrapped = wrapped: {
var wrapped: usize = 0;
// If shrinking rows (in the .lt branch of resize, rows shrink
// before we get here) pushed the cursor pin above the new active
// area, there are no rows to count and iterating .left_up toward
// the active-area top would be an invalid (reversed) range. The
// preserved-cursor growth below already no-ops for a cursor that
// isn't in the active area, so we just count zero here.
if (active_pin) |ap| {
if (p.before(ap)) break :wrapped 0;
}
var row_it = p.rowIterator(.left_up, active_pin);
while (row_it.next()) |next| {
const row = next.rowAndCell().row;
@@ -10859,6 +10869,58 @@ test "PageList resize less rows and cols cursor at bottom" {
} }, s.pointFromPin(.active, cursor_pin.*).?);
}
test "PageList resize less rows and cols cursor near top pushed to scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 80, 24, null);
defer s.deinit();
// Fill every active row with non-blank content so that shrinking rows
// can't trim trailing blank lines and instead pushes the top rows into
// scrollback.
{
var it = s.rowIterator(.right_down, .{ .active = .{} }, null);
while (it.next()) |p| {
const rac = p.rowAndCell();
const cells = p.node.data.getCells(rac.row);
for (cells, 0..) |*cell, x| cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = @intCast('A' + (x % 26)) },
};
}
}
// Cursor near the top of the active area. After we shrink rows the active
// area top moves down past this pin, so it ends up in scrollback.
const cursor_pin = try s.trackPin(s.pin(.{ .active = .{
.x = 0,
.y = 0,
} }).?);
defer s.untrackPin(cursor_pin);
// Shrink both axes with reflow. resizeWithoutReflow shrinks self.rows
// first, leaving the cursor pin above the new active area, then resizeCols
// walks .left_up from the cursor pin toward the active-area top.
try s.resize(.{
.cols = 79,
.rows = 20,
.reflow = true,
.cursor = .{ .x = 0, .y = 0, .pin = cursor_pin },
});
try testing.expectEqual(@as(usize, 79), s.cols);
try testing.expectEqual(@as(usize, 20), s.rows);
// The active area is anchored to the bottom, so shrinking rows pushed the
// top-of-screen cursor into scrollback: it no longer resolves to an
// active-area coordinate, but it remains a valid screen pin.
try testing.expect(s.pointFromPin(.active, cursor_pin.*) == null);
try testing.expect(s.pointFromPin(.screen, cursor_pin.*) != null);
// Integrity must hold after the resize.
s.assertIntegrity();
}
test "PageList resize (no reflow) more rows and less cols" {
const testing = std.testing;
const alloc = testing.allocator;