Skip to content

Commit 256281c

Browse files
authored
terminal: reflow the saved cursor if we have one (#5720)
Fixes #5718 When a terminal is resized with text reflow (i.e. soft-wrapped text), the cursor is generally reflowed with it. For example, imagine a terminal window 5-columns wide and you type the following without pressing enter. The cursor is on the X. ``` OOOOO OOX ``` If you resize the window now to 8 or more columns, this happens, as expected: ``` OOOOOOOX ``` As expected, the cursor remains on the "X". This behaves like any other text input... Terminals also provide an escape sequence to [save the cursor (ESC 7 aka DECSC)](https://ghostty.org/docs/vt/esc/decsc). This includes, amongst other things, the cursor position. The cursor can be restored with [DECRC](https://ghostty.org/docs/vt/esc/decrc). The behavior of the position of the _saved cursor_ in the context of text reflow is unspecified and varies wildly between terminals Ghostty does this right now (as do many other terminals): ``` OOOOOOOO X ``` This commit changes the behavior so that we reflow the saved cursor.
2 parents 99cbc06 + 7dac9e0 commit 256281c

File tree

2 files changed

+123
-0
lines changed

2 files changed

+123
-0
lines changed

src/terminal/Screen.zig

+42
Original file line numberDiff line numberDiff line change
@@ -1590,6 +1590,18 @@ fn resizeInternal(
15901590
self.cursor.hyperlink = null;
15911591
}
15921592

1593+
// We need to insert a tracked pin for our saved cursor so we can
1594+
// modify its X/Y for reflow.
1595+
const saved_cursor_pin: ?*Pin = saved_cursor: {
1596+
const sc = self.saved_cursor orelse break :saved_cursor null;
1597+
const pin = self.pages.pin(.{ .active = .{
1598+
.x = sc.x,
1599+
.y = sc.y,
1600+
} }) orelse break :saved_cursor null;
1601+
break :saved_cursor try self.pages.trackPin(pin);
1602+
};
1603+
defer if (saved_cursor_pin) |p| self.pages.untrackPin(p);
1604+
15931605
// Perform the resize operation.
15941606
try self.pages.resize(.{
15951607
.rows = rows,
@@ -1609,6 +1621,36 @@ fn resizeInternal(
16091621
// state is correct.
16101622
self.cursorReload();
16111623

1624+
// If we reflowed a saved cursor, update it.
1625+
if (saved_cursor_pin) |p| {
1626+
// This should never fail because a non-null saved_cursor_pin
1627+
// implies a non-null saved_cursor.
1628+
const sc = &self.saved_cursor.?;
1629+
if (self.pages.pointFromPin(.active, p.*)) |pt| {
1630+
sc.x = @intCast(pt.active.x);
1631+
sc.y = @intCast(pt.active.y);
1632+
1633+
// If we had pending wrap set and we're no longer at the end of
1634+
// the line, we unset the pending wrap and move the cursor to
1635+
// reflect the correct next position.
1636+
if (sc.pending_wrap and sc.x != cols - 1) {
1637+
sc.pending_wrap = false;
1638+
sc.x += 1;
1639+
}
1640+
} else {
1641+
// I think this can happen if the screen is resized to be
1642+
// less rows or less cols and our saved cursor moves outside
1643+
// the active area. In this case, there isn't anything really
1644+
// reasonable we can do so we just move the cursor to the
1645+
// top-left. It may be reasonable to also move the cursor to
1646+
// match the primary cursor. Any behavior is fine since this is
1647+
// totally unspecified.
1648+
sc.x = 0;
1649+
sc.y = 0;
1650+
sc.pending_wrap = false;
1651+
}
1652+
}
1653+
16121654
// Fix up our hyperlink if we had one.
16131655
if (hyperlink_) |link| {
16141656
self.startHyperlink(link.uri, switch (link.id) {

src/terminal/Terminal.zig

+81
Original file line numberDiff line numberDiff line change
@@ -10708,6 +10708,87 @@ test "Terminal: resize with high unique style per cell with wrapping" {
1070810708
try t.resize(alloc, 60, 30);
1070910709
}
1071010710

10711+
test "Terminal: resize with reflow and saved cursor" {
10712+
const alloc = testing.allocator;
10713+
var t = try init(alloc, .{ .cols = 2, .rows = 3 });
10714+
defer t.deinit(alloc);
10715+
try t.printString("1A2B");
10716+
t.setCursorPos(2, 2);
10717+
{
10718+
const list_cell = t.screen.pages.getCell(.{ .active = .{
10719+
.x = t.screen.cursor.x,
10720+
.y = t.screen.cursor.y,
10721+
} }).?;
10722+
const cell = list_cell.cell;
10723+
try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint);
10724+
}
10725+
10726+
{
10727+
const str = try t.plainString(testing.allocator);
10728+
defer testing.allocator.free(str);
10729+
try testing.expectEqualStrings("1A\n2B", str);
10730+
}
10731+
10732+
t.saveCursor();
10733+
try t.resize(alloc, 5, 3);
10734+
try t.restoreCursor();
10735+
10736+
{
10737+
const str = try t.plainString(testing.allocator);
10738+
defer testing.allocator.free(str);
10739+
try testing.expectEqualStrings("1A2B", str);
10740+
}
10741+
10742+
// Verify our cursor is still in the same place
10743+
{
10744+
const list_cell = t.screen.pages.getCell(.{ .active = .{
10745+
.x = t.screen.cursor.x,
10746+
.y = t.screen.cursor.y,
10747+
} }).?;
10748+
const cell = list_cell.cell;
10749+
try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint);
10750+
}
10751+
}
10752+
10753+
test "Terminal: resize with reflow and saved cursor pending wrap" {
10754+
const alloc = testing.allocator;
10755+
var t = try init(alloc, .{ .cols = 2, .rows = 3 });
10756+
defer t.deinit(alloc);
10757+
try t.printString("1A2B");
10758+
{
10759+
const list_cell = t.screen.pages.getCell(.{ .active = .{
10760+
.x = t.screen.cursor.x,
10761+
.y = t.screen.cursor.y,
10762+
} }).?;
10763+
const cell = list_cell.cell;
10764+
try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint);
10765+
}
10766+
10767+
{
10768+
const str = try t.plainString(testing.allocator);
10769+
defer testing.allocator.free(str);
10770+
try testing.expectEqualStrings("1A\n2B", str);
10771+
}
10772+
10773+
t.saveCursor();
10774+
try t.resize(alloc, 5, 3);
10775+
try t.restoreCursor();
10776+
10777+
{
10778+
const str = try t.plainString(testing.allocator);
10779+
defer testing.allocator.free(str);
10780+
try testing.expectEqualStrings("1A2B", str);
10781+
}
10782+
10783+
// Pending wrap should be reset
10784+
try t.print('X');
10785+
{
10786+
const str = try t.plainString(testing.allocator);
10787+
defer testing.allocator.free(str);
10788+
try testing.expectEqualStrings("1A2BX", str);
10789+
}
10790+
}
10791+
1071110792
test "Terminal: DECCOLM without DEC mode 40" {
1071210793
const alloc = testing.allocator;
1071310794
var t = try init(alloc, .{ .rows = 5, .cols = 5 });

0 commit comments

Comments
 (0)