Fix zero-width grapheme attachment during pending wrap

This PR fixes an issue where a zero-width combining mark could attach to
the wrong cell when the preceding character was written in the final
column and the cursor had a pending wrap.
This commit is contained in:
Riccardo Mazzarini
2026-05-04 22:39:07 +02:00
parent 1547dd667a
commit c20fcfa1b4
2 changed files with 52 additions and 7 deletions

View File

@@ -573,20 +573,25 @@ pub fn print(self: *Terminal, c: u21) !void {
// it.
if (self.modes.get(.grapheme_cluster)) return;
// If we're at cell zero, then this is malformed data and we don't
// print anything or even store this. Zero-width characters are ALWAYS
// attached to some other non-zero-width character at the time of
// writing.
if (self.screens.active.cursor.x == 0) {
// If we have wraparound enabled and a pending wrap, the character
// we're attaching to is still under the cursor. Otherwise, it's the
// cell to the left.
const left: size.CellCountInt = if (self.modes.get(.wraparound) and self.screens.active.cursor.pending_wrap) 0 else 1;
// If we're at cell zero and not pending a wrap, then this is malformed
// data and we don't print anything or even store this. Zero-width
// characters are ALWAYS attached to some other non-zero-width
// character at the time of writing.
if (self.screens.active.cursor.x == 0 and left == 1) {
log.warn("zero-width character with no prior character, ignoring", .{});
return;
}
// Find our previous cell
const prev = prev: {
const immediate = self.screens.active.cursorCellLeft(1);
const immediate = self.screens.active.cursorCellLeft(left);
if (immediate.wide != .spacer_tail) break :prev immediate;
break :prev self.screens.active.cursorCellLeft(2);
break :prev self.screens.active.cursorCellLeft(left + 1);
};
// If our previous cell has no text, just ignore the zero-width character
@@ -3313,6 +3318,23 @@ test "Terminal: zero-width character at start" {
try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } }));
}
// https://github.com/ghostty-org/ghostty/issues/12581
test "Terminal: zero-width character attaches to pending wrap cell" {
var t = try init(testing.allocator, .{ .cols = 2, .rows = 2 });
defer t.deinit(testing.allocator);
// Disable grapheme clustering to exercise the fallback path.
t.modes.set(.grapheme_cluster, false);
try t.print('x');
try t.print('å');
try t.print(0x0332); // Combining low line.
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("xå̲", str);
}
// https://github.com/mitchellh/ghostty/issues/1400
test "Terminal: print single very long line" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 });

View File

@@ -1052,6 +1052,29 @@ test "vt_write split escape sequence" {
try testing.expectEqualStrings("Hello Bold", str);
}
test "vt_write split combining mark after base at right edge" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 2,
.rows = 2,
.max_scrollback = 0,
},
));
defer free(t);
// Put "å" in the final column, then send its combining low line in a
// separate write so the mark arrives while the cursor has a pending wrap.
vt_write(t, "", 3);
vt_write(t, "\xcc\xb2", 2);
const str = try t.?.terminal.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("xå̲", str);
}
test "get cols and rows" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(