From b287f6d1ab98e92b05ae6a2b4ea29d4daad9d3bd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 21:34:39 -0700 Subject: [PATCH] terminal: handle stored grapheme boundaries With mode 2027 disabled, the printer attaches zero-width codepoints without applying Unicode grapheme boundaries. Enabling the mode later and printing another non-ASCII codepoint asserted that every stored pair was part of one grapheme and panicked when it was not. Feed all stored codepoints through the grapheme state machine and let it reset at existing boundaries before testing the new codepoint. The deterministic print comparison can now exercise live mode changes without clearing the screen first. --- src/terminal/Terminal.zig | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 3bcfdf5d9..05085721a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -952,7 +952,13 @@ pub fn print(self: *Terminal, c: u21) !void { const cps = self.screens.active.cursor.page_pin.node.page().lookupGrapheme(prev.cell).?; for (cps) |cp2| { // log.debug("cp1={x} cp2={x}", .{ previous_codepoint, cp2 }); - assert(!unicode.graphemeBreak(previous_codepoint, cp2, &state)); + // With mode 2027 disabled, zero-width codepoints are + // attached without applying grapheme boundary rules. If + // the mode is enabled later, an existing cell can + // therefore contain one or more breaks. Feed those breaks + // into the state machine so it can reset its context and + // determine the boundary for the new codepoint. + _ = unicode.graphemeBreak(previous_codepoint, cp2, &state); previous_codepoint = cp2; } } @@ -4297,6 +4303,22 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } +test "Terminal: enabling grapheme mode handles stored breaks" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, false); + try t.print('a'); + try t.print(0x200B); // Zero width space is stored on the prior cell. + + t.modes.set(.grapheme_cluster, true); + try t.print(0x0301); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("a\xE2\x80\x8B\xCC\x81", str); +} + // Terminal.print receives one codepoint at a time, so it can't use // unicode.graphemeWidth directly; that API requires a complete buffered // cluster or string end. This keeps the streaming printer's cursor advance @@ -12491,13 +12513,6 @@ fn testPrintSliceDifferential( }, 15 => { const v = rand.boolean(); - // Erase the display first: grapheme clusters created - // while mode 2027 was off can trip a pre-existing - // debug assert in print()'s cluster walk when the mode - // is toggled on (unrelated to printSlice; it reproduces - // with per-codepoint print alone). - t1.eraseDisplay(.complete, false); - t2.eraseDisplay(.complete, false); t1.modes.set(.grapheme_cluster, v); t2.modes.set(.grapheme_cluster, v); },