fix: preserve active cursor position during reflow (#12598)

This PR fixes an issue where reflowing could leave the active cursor
attached to a clipped trailing blank cell instead of following the
current write position.
This commit is contained in:
Mitchell Hashimoto
2026-05-22 09:03:52 -07:00
committed by GitHub
2 changed files with 73 additions and 9 deletions

View File

@@ -935,6 +935,10 @@ pub const Resize = struct {
pub const Cursor = struct {
x: size.CellCountInt,
y: size.CellCountInt,
/// When set, this pin preserves right-side blank cells up to the cursor
/// during reflow.
pin: ?*Pin = null,
};
};
@@ -1013,10 +1017,6 @@ fn resizeCols(
) Allocator.Error!void {
assert(cols != self.cols);
// Update our cols. We have to do this early because grow() that we
// may call below relies on this to calculate the proper page size.
self.cols = cols;
// If we have a cursor position (x,y), then we try under any col resizing
// to keep the same number remaining active rows beneath it. This is a
// very special case if you can imagine clearing the screen (i.e.
@@ -1025,10 +1025,11 @@ fn resizeCols(
// pull down scrollback.
const preserved_cursor: ?struct {
tracked_pin: *Pin,
untrack: bool,
remaining_rows: usize,
wrapped_rows: usize,
} = if (cursor) |c| cursor: {
const p = self.pin(.{ .active = .{
const p = if (c.pin) |cursor_pin| cursor_pin.* else self.pin(.{ .active = .{
.x = c.x,
.y = c.y,
} }) orelse break :cursor null;
@@ -1051,12 +1052,21 @@ fn resizeCols(
};
break :cursor .{
.tracked_pin = try self.trackPin(p),
.tracked_pin = c.pin orelse try self.trackPin(p),
.untrack = c.pin == null,
.remaining_rows = self.rows - c.y - 1,
.wrapped_rows = wrapped,
};
} else null;
defer if (preserved_cursor) |c| self.untrackPin(c.tracked_pin);
defer if (preserved_cursor) |c| {
if (c.untrack) self.untrackPin(c.tracked_pin);
};
// Update our cols. We have to do this early because grow() that we
// may call below relies on this to calculate the proper page size, but
// after preserved_cursor so that the cursor pin can resolve coordinates in
// the old active coordinate space.
self.cols = cols;
// Create the first node that contains our reflow.
const first_rewritten_node = node: {
@@ -1110,7 +1120,11 @@ fn resizeCols(
{
var reflow_cursor: ReflowCursor = .init(first_rewritten_node);
while (it.next()) |row| {
try reflow_cursor.reflowRow(self, row);
try reflow_cursor.reflowRow(
self,
row,
if (preserved_cursor) |c| c.tracked_pin else null,
);
// Once we're done reflowing a page, destroy it immediately.
// This frees memory and makes it more likely in memory
@@ -1226,6 +1240,7 @@ const ReflowCursor = struct {
self: *ReflowCursor,
list: *PageList,
row: Pin,
cursor_pin: ?*Pin,
) Allocator.Error!void {
const src_page: *Page = &row.node.data;
const src_row = row.rowAndCell().row;
@@ -1253,6 +1268,8 @@ const ReflowCursor = struct {
if (&p.node.data != src_page or
p.y != src_y) continue;
if (cursor_pin != null and p == cursor_pin.?) continue;
// If this pin is in the blanks on the right and past the end
// of the dst col width then we move it to the end of the dst
// col width instead.
@@ -1268,6 +1285,14 @@ const ReflowCursor = struct {
}
}
// If the cursor is after blanks on the right, those cells are still
// before the next write and must reflow with it.
if (cursor_pin) |p| {
if (&p.node.data == src_page and p.y == src_y) {
cols_len = @max(cols_len, p.x + 1);
}
}
// Defer processing of blank rows so that blank rows
// at the end of the page list are never written.
if (cols_len == 0) {

View File

@@ -1766,7 +1766,11 @@ pub inline fn resize(
.rows = opts.rows,
.cols = opts.cols,
.reflow = opts.reflow,
.cursor = .{ .x = self.cursor.x, .y = self.cursor.y },
.cursor = .{
.x = self.cursor.x,
.y = self.cursor.y,
.pin = self.cursor.page_pin,
},
});
// If we have no scrollback and we shrunk our rows, we must explicitly
@@ -7277,6 +7281,41 @@ test "Screen: resize less cols to eliminate wide char with row space" {
}
}
test "Screen: resize less cols reflows cursor after wrapped text" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 50, .rows = 7, .max_scrollback = 0 });
defer s.deinit();
for (0..30) |_| try s.testWriteString("a");
try testing.expectEqual(@as(usize, 0), s.cursor.y);
try testing.expectEqual(@as(usize, 30), s.cursor.x);
try s.resize(.{ .cols = 25, .rows = 7 });
try testing.expectEqual(@as(usize, 1), s.cursor.y);
try testing.expectEqual(@as(usize, 5), s.cursor.x);
}
test "Screen: resize less cols reflows cursor after empty cells" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer s.deinit();
try s.testWriteString("abc");
s.cursorRight(6);
try testing.expectEqual(@as(usize, 0), s.cursor.y);
try testing.expectEqual(@as(usize, 9), s.cursor.x);
try s.resize(.{ .cols = 5, .rows = 3 });
try testing.expectEqual(@as(usize, 1), s.cursor.y);
try testing.expectEqual(@as(usize, 4), s.cursor.x);
}
test "Screen: resize more cols with wide spacer head" {
const testing = std.testing;
const alloc = testing.allocator;