From c44afa62506dd94b9dcc680da412ddb650931b3b Mon Sep 17 00:00:00 2001 From: Riccardo Mazzarini Date: Wed, 6 May 2026 14:52:36 +0200 Subject: [PATCH] fix: preserve active cursor position during reflow 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. --- src/terminal/PageList.zig | 41 +++++++++++++++++++++++++++++++-------- src/terminal/Screen.zig | 41 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 17eea73d0..89fdaec1f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -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) { diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 39fdd6109..ac53a2d72 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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;