terminal: saturate cursor subtraction in resizeCols (#12907)

PageList.resize takes the .lt branch when columns shrink, which calls
resizeWithoutReflow (mutating self.rows to the new smaller value) and
then resizeCols with the original opts.cursor.y. When both axes shrink
in one call and the cursor sits at or past the new bottom row, the
expression `self.rows - c.y - 1` underflows and panics in safety builds.

Use saturating subtraction; "remaining rows below cursor" is 0 once the
cursor sits at or past the new bottom.

This problem is reported by
[discussion#12905](https://github.com/ghostty-org/ghostty/discussions/12905)
This commit is contained in:
Mitchell Hashimoto
2026-06-04 11:02:41 -07:00
committed by GitHub
2 changed files with 57 additions and 2 deletions

View File

@@ -1059,7 +1059,7 @@ fn resizeCols(
break :cursor .{
.tracked_pin = c.pin orelse try self.trackPin(p),
.untrack = c.pin == null,
.remaining_rows = self.rows - c.y - 1,
.remaining_rows = self.rows -| (c.y + 1),
.wrapped_rows = wrapped,
};
} else null;
@@ -1192,7 +1192,7 @@ fn resizeCols(
break :wrapped wrapped;
};
const current = self.rows - active_pt.active.y - 1;
const current = self.rows -| (active_pt.active.y + 1);
var req_rows = c.remaining_rows;
req_rows -|= wrapped -| c.wrapped_rows;
@@ -10828,6 +10828,37 @@ test "PageList resize (no reflow) less rows and cols" {
}
}
test "PageList resize less rows and cols cursor at bottom" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 80, 24, 0);
defer s.deinit();
const cursor_pin = try s.trackPin(s.pin(.{ .active = .{
.x = 0,
.y = s.rows - 1,
} }).?);
defer s.untrackPin(cursor_pin);
// Shrink both axes such that the original cursor.y is strictly past the
// new row count, so resizeWithoutReflow leaves self.rows < c.y + 1.
try s.resize(.{
.cols = 79,
.rows = 20,
.reflow = true,
.cursor = .{ .x = 0, .y = 23, .pin = cursor_pin },
});
try testing.expectEqual(@as(usize, 79), s.cols);
try testing.expectEqual(@as(usize, 20), s.rows);
// remaining_rows saturates to 0, so the cursor lands on the new bottom row.
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = s.rows - 1,
} }, s.pointFromPin(.active, cursor_pin.*).?);
}
test "PageList resize (no reflow) more rows and less cols" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -1005,6 +1005,30 @@ test "resize invalid value" {
try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18));
}
test "resize shrinks both axes with cursor at bottom" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
// CSI 24;1H -> park the cursor on the bottom row (1-based).
const move = "\x1b[24;1H";
vt_write(t, move, move.len);
// Shrink both axes; pre-resize cursor.y sits past the new bottom row.
// Previously this underflowed in PageList.resizeCols.
try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16));
try testing.expectEqual(79, t.?.terminal.cols);
try testing.expectEqual(23, t.?.terminal.rows);
}
test "mode_get and mode_set" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(