terminal: avoid VS15 cursor underflow

Handle VS15 width changes when the wide grapheme base is directly under
the cursor. Cover both disabled wraparound and restored pending-wrap
cursor states.

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.

Find the spacer from the wide base instead of subtracting from the
cursor distance. Reposition the cursor from the base column and clamp it
to the active right margin.
This commit is contained in:
Mitchell Hashimoto
2026-08-05 08:55:00 -07:00
parent 5944ab286d
commit 33d34cf5ce

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);