terminal: avoid VS15 cursor underflow (#13631)

Handle VS15 width changes when the wide grapheme base is directly under
the cursor.

A zero cursor distance previously underflowed while locating the spacer
tail. Debug builds panicked and ReleaseFast computed an out-of-bounds
cell pointer before updating it.
This commit is contained in:
Mitchell Hashimoto
2026-08-05 09:10:46 -07:00
committed by GitHub

View File

@@ -1314,28 +1314,24 @@ pub fn print(self: *Terminal, c: u21) !void {
if (prev.cell.wide != .wide) break :narrow;
prev.cell.wide = .narrow;
// Remove the wide spacer tail
const cell = self.screens.active.cursorCellLeft(prev.left - 1);
cell.wide = .narrow;
// Back track the cursor so that we don't end up with
// an extra space after the character. Since xterm is
// not VS aware, it cannot be used as a reference for
// this behavior; but it does follow the principle of
// least surprise, and also matches the behavior that
// can be observed in Kitty, which is one of the only
// other VS aware terminals.
if (self.screens.active.cursor.x == right_limit - 1) {
// If we're already at the right edge, we stay
// here and set the pending wrap to false since
// when we pend a wrap, we only move our cursor once
// even for wide chars (tests verify).
self.screens.active.cursor.pending_wrap = false;
} else {
// Otherwise, move back.
self.screens.active.cursorLeft(1);
// Remove the wide spacer tail. The previous cell may be
// under the cursor, so locate the tail from the wide base
// rather than by subtracting from the cursor distance.
const prev_x = self.screens.active.cursor.x - prev.left;
if (prev_x < self.cols - 1) {
const cells: [*]Cell = @ptrCast(prev.cell);
cells[1].wide = .narrow;
}
// Place the cursor one cell after the now-narrow base,
// clamped to the right edge. Usually this moves the cursor
// back from after the old tail, but saved cursor state or
// changed margins can leave it directly on the base.
self.screens.active.cursor.pending_wrap = false;
self.screens.active.cursorHorizontalAbsolute(
@min(prev_x + 1, right_limit - 1),
);
break :narrow;
},
@@ -5746,6 +5742,68 @@ test "Terminal: VS15 to make narrow character with pending wrap" {
}
}
test "Terminal: VS15 narrows wide cell under cursor with wraparound disabled" {
var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 });
defer t.deinit(testing.allocator);
t.modes.set(.grapheme_cluster, true);
t.modes.set(.wraparound, false);
// First create a wide cell spanning columns 4 and 5.
t.setCursorPos(1, 4);
try t.print(0x2614);
// Make column 4 the right margin and put the cursor on the wide base.
// With wraparound disabled, grapheme lookup selects the cell under the
// cursor when it has content.
t.modes.set(.enable_left_and_right_margin, true);
t.setLeftAndRightMargin(1, 4);
t.setCursorPos(1, 4);
try t.print(0xFE0E);
try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x);
try testing.expect(!t.screens.active.cursor.pending_wrap);
const base = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?.cell;
try testing.expectEqual(Cell.Wide.narrow, base.wide);
try testing.expect(base.hasGrapheme());
const tail = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?.cell;
try testing.expectEqual(Cell.Wide.narrow, tail.wide);
}
test "Terminal: VS15 narrows wide cell under restored pending cursor" {
var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 });
defer t.deinit(testing.allocator);
t.modes.set(.grapheme_cluster, true);
t.modes.set(.enable_left_and_right_margin, true);
t.setLeftAndRightMargin(1, 4);
// Save a pending-wrap cursor at column 4.
t.setCursorPos(1, 4);
try t.print('X');
try testing.expect(t.screens.active.cursor.pending_wrap);
t.saveCursor();
// Widen the margin and replace that cell with a wide character.
t.setLeftAndRightMargin(1, 5);
t.setCursorPos(1, 4);
try t.print(0x2614);
// Restoring also restores pending_wrap, so grapheme lookup selects the
// wide base under the cursor rather than its spacer tail.
t.restoreCursor();
try testing.expect(t.screens.active.cursor.pending_wrap);
try t.print(0xFE0E);
try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x);
try testing.expect(!t.screens.active.cursor.pending_wrap);
const base = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?.cell;
try testing.expectEqual(Cell.Wide.narrow, base.wide);
try testing.expect(base.hasGrapheme());
const tail = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?.cell;
try testing.expectEqual(Cell.Wide.narrow, tail.wide);
}
test "Terminal: VS16 to make wide character on next line" {
var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 });
defer t.deinit(testing.allocator);