diff --git a/packages/core/src/renderables/Text.test.ts b/packages/core/src/renderables/Text.test.ts index fa1eebba2..537fa63df 100644 --- a/packages/core/src/renderables/Text.test.ts +++ b/packages/core/src/renderables/Text.test.ts @@ -1464,6 +1464,43 @@ describe("TextRenderable Selection", () => { expect(frame).toMatchSnapshot() }) + it("should render CJK text with wrapping at boundary correctly", async () => { + await createTextRenderable(currentRenderer, { + content: "123456789中文测试", + wrapMode: "char", + width: 10, + left: 0, + top: 0, + }) + + const frame = captureFrame() + expect(frame).not.toContain("?") + expect(frame).toContain("中") + expect(frame).toContain("文") + }) + + it("should render CJK correctly during streaming append", async () => { + const { text } = await createTextRenderable(currentRenderer, { + content: "12345678", + wrapMode: "char", + width: 10, + left: 0, + top: 0, + }) + + await renderOnce() + const frame1 = captureFrame() + expect(frame1).toContain("12345678") + + text.appendText("9中文") + await renderOnce() + + const frame2 = captureFrame() + expect(frame2).not.toContain("?") + expect(frame2).toContain("中") + expect(frame2).toContain("文") + }) + it("should render wrapped multiline text correctly", async () => { await createTextRenderable(currentRenderer, { content: "First line with long content\nSecond line also with content\nThird line", diff --git a/packages/core/src/renderables/TextBufferRenderable.ts b/packages/core/src/renderables/TextBufferRenderable.ts index a4243a029..fb6d7afc9 100644 --- a/packages/core/src/renderables/TextBufferRenderable.ts +++ b/packages/core/src/renderables/TextBufferRenderable.ts @@ -141,6 +141,11 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf return this.textBufferView.getVirtualLineCount() } + public appendText(text: string): void { + this.textBuffer.append(text) + this.updateTextInfo() + } + public get scrollY(): number { return this._scrollY } diff --git a/packages/core/src/renderables/__tests__/Textarea.keybinding.test.ts b/packages/core/src/renderables/__tests__/Textarea.keybinding.test.ts index caab5f97e..62ec965eb 100644 --- a/packages/core/src/renderables/__tests__/Textarea.keybinding.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.keybinding.test.ts @@ -511,6 +511,52 @@ describe("Textarea - Keybinding Tests", () => { expect(editor.plainText).toBe("🌟世 👍") }) + it("should handle ZWJ emoji sequences as single grapheme", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: "", + width: 40, + height: 10, + }) + + editor.focus() + + const familyHandled = editor.handleKeyPress(createKeyEvent("👨‍👩‍👧‍👦")) + expect(familyHandled).toBe(true) + expect(editor.plainText).toBe("👨‍👩‍👧‍👦") + + const astronautHandled = editor.handleKeyPress(createKeyEvent("👩‍🚀")) + expect(astronautHandled).toBe(true) + expect(editor.plainText).toBe("👨‍👩‍👧‍👦👩‍🚀") + }) + + it("should handle flag emoji as single grapheme", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: "", + width: 40, + height: 10, + }) + + editor.focus() + + const flagHandled = editor.handleKeyPress(createKeyEvent("🇺🇸")) + expect(flagHandled).toBe(true) + expect(editor.plainText).toBe("🇺🇸") + }) + + it("should handle skin tone emoji as single grapheme", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: "", + width: 40, + height: 10, + }) + + editor.focus() + + const skinToneHandled = editor.handleKeyPress(createKeyEvent("👋🏻")) + expect(skinToneHandled).toBe(true) + expect(editor.plainText).toBe("👋🏻") + }) + it("should filter escape sequences when they have non-printable characters", async () => { const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { initialValue: "Test", diff --git a/packages/core/src/text-buffer-view.test.ts b/packages/core/src/text-buffer-view.test.ts index 0e4de9d2d..6640c9d05 100644 --- a/packages/core/src/text-buffer-view.test.ts +++ b/packages/core/src/text-buffer-view.test.ts @@ -547,6 +547,101 @@ describe("TextBufferView", () => { }) }) + describe("CJK character at line boundary", () => { + it("should NOT overflow when CJK character at position width-1", () => { + // Width 10, fill 9 ASCII chars, then a CJK char (width 2) + // "123456789中" - 中 should wrap to next line, leaving 1 space at end of first line + const text = "123456789中" + const styledText = stringToStyledText(text) + buffer.setStyledText(styledText) + + view.setWrapMode("char") + view.setWrapWidth(10) + + const lineInfo = view.lineInfo + // Should be 2 lines: "123456789" (9 cols) + "中" (2 cols) + expect(lineInfo.lineStarts.length).toBe(2) + // First line should NOT exceed wrap width + expect(lineInfo.lineWidths[0]).toBeLessThanOrEqual(10) + // CJK should be on second line + expect(lineInfo.lineWidths[1]).toBe(2) + }) + + it("should NOT overflow when CJK character exactly fills remaining space", () => { + // Width 10, fill 8 ASCII chars, then a CJK char (width 2) = exactly 10 + const text = "12345678中" + const styledText = stringToStyledText(text) + buffer.setStyledText(styledText) + + view.setWrapMode("char") + view.setWrapWidth(10) + + const lineInfo = view.lineInfo + // Should fit on 1 line: "12345678中" = 8 + 2 = 10 + expect(lineInfo.lineStarts.length).toBe(1) + expect(lineInfo.lineWidths[0]).toBe(10) + }) + + it("should handle multiple CJK chars near boundary correctly", () => { + // Width 10, "1234567中中" = 7 + 2 + 2 = 11, should wrap after first 中 + const text = "1234567中中" + const styledText = stringToStyledText(text) + buffer.setStyledText(styledText) + + view.setWrapMode("char") + view.setWrapWidth(10) + + const lineInfo = view.lineInfo + // First line: "1234567中" = 7 + 2 = 9 (fits) or wrap before second 中 + // Each line should NOT exceed 10 + for (const width of lineInfo.lineWidths) { + expect(width).toBeLessThanOrEqual(10) + } + }) + + it("should handle streaming append with CJK at boundary", () => { + // Simulate streaming: first send "123456789", then append "中" + const styledText1 = stringToStyledText("123456789") + buffer.setStyledText(styledText1) + + view.setWrapMode("char") + view.setWrapWidth(10) + + const lineInfo1 = view.lineInfo + expect(lineInfo1.lineWidths[0]).toBe(9) + + // Now append a CJK character + buffer.append("中") + + const lineInfo2 = view.lineInfo + // Should be 2 lines now + expect(lineInfo2.lineStarts.length).toBe(2) + // Neither line should exceed wrap width + expect(lineInfo2.lineWidths[0]).toBeLessThanOrEqual(10) + expect(lineInfo2.lineWidths[1]).toBeLessThanOrEqual(10) + }) + + it("should leave padding when CJK cannot fit at line end", () => { + // Width 10, "123456789中文" = 9 + 2 + 2 = 13 + // Line 1: "123456789" (9) - 中 doesn't fit (would be 11), leave 1 padding + // Line 2: "中文" (4) + const text = "123456789中文" + const styledText = stringToStyledText(text) + buffer.setStyledText(styledText) + + view.setWrapMode("char") + view.setWrapWidth(10) + + const lineInfo = view.lineInfo + // First line should be 9 (not 10, because CJK needs 2 cols) + expect(lineInfo.lineWidths[0]).toBeLessThanOrEqual(10) + // Ensure no line exceeds wrap width + for (const width of lineInfo.lineWidths) { + expect(width).toBeLessThanOrEqual(10) + } + }) + }) + describe("measureForDimensions", () => { it("should measure without modifying cache", () => { const styledText = stringToStyledText("ABCDEFGHIJKLMNOPQRST") diff --git a/packages/core/src/zig/tests/text-buffer-drawing_test.zig b/packages/core/src/zig/tests/text-buffer-drawing_test.zig index 8b0a4628a..c0f0fc0a9 100644 --- a/packages/core/src/zig/tests/text-buffer-drawing_test.zig +++ b/packages/core/src/zig/tests/text-buffer-drawing_test.zig @@ -3094,3 +3094,80 @@ test "drawTextBuffer - Thai ว่ grapheme in quotes occupies one cell" { try std.testing.expect(std.mem.indexOf(u8, result, "\"ว่\"") != null); } + +test "drawTextBuffer - streaming append with CJK should render all characters" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, .unicode); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + // Initial content: "12345678" (8 ASCII chars) + try tb.setText("12345678"); + + // Set up char wrapping at width 10 + view.setWrapMode(.char); + view.setWrapWidth(10); + view.updateVirtualLines(); + + var opt_buffer = try OptimizedBuffer.init( + std.testing.allocator, + 20, + 5, + .{ .pool = pool, .width_method = .unicode }, + ); + defer opt_buffer.deinit(); + + // First render + try opt_buffer.clear(.{ 0.0, 0.0, 0.0, 1.0 }, 32); + try opt_buffer.drawTextBuffer(view, 0, 0); + + var buf: [500]u8 = undefined; + var buf_len = try opt_buffer.writeResolvedChars(&buf, false); + var result = buf[0..buf_len]; + + // Verify initial content + try std.testing.expect(std.mem.indexOf(u8, result, "12345678") != null); + + // Now append "9中文" - this is the streaming append case + try tb.append("9中文"); + + // After append, the view should be dirty + try std.testing.expect(tb.isViewDirty(view.view_id)); + + // Update virtual lines (simulating what happens in render) + view.updateVirtualLines(); + + // View should no longer be dirty after update + try std.testing.expect(!tb.isViewDirty(view.view_id)); + + // Get virtual line count + const vline_count = view.getVirtualLineCount(); + // Content "123456789中文" (9 ASCII + 2 CJK) = 9 + 4 = 13 columns + // With width 10: line 1 = "123456789" (9), line 2 = "中文" (4) + // So we should have 2 virtual lines + try std.testing.expectEqual(@as(u32, 2), vline_count); + + // Second render after append + try opt_buffer.clear(.{ 0.0, 0.0, 0.0, 1.0 }, 32); + try opt_buffer.drawTextBuffer(view, 0, 0); + + buf_len = try opt_buffer.writeResolvedChars(&buf, false); + result = buf[0..buf_len]; + + // Verify the content is valid UTF-8 + try std.testing.expect(std.unicode.utf8ValidateSlice(result)); + + // Verify "123456789" is present + try std.testing.expect(std.mem.indexOf(u8, result, "123456789") != null); + + // CRITICAL: Verify CJK characters are present + try std.testing.expect(std.mem.indexOf(u8, result, "中") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "文") != null); + + // Verify no question marks (corruption indicator) + try std.testing.expect(std.mem.indexOf(u8, result, "?") == null); +}