diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index f6268c719..6740a93e6 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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 }); diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 670c2aea3..d3293f3bc 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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, "xå", 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(