Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/core/src/renderables/Text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1403,6 +1403,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",
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/renderables/TextBufferRenderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/text-buffer-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
80 changes: 77 additions & 3 deletions packages/core/src/zig/tests/text-buffer-drawing_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2310,7 +2310,6 @@ test "drawTextBuffer - setStyledText with multiple colors and horizontal scrolli
try opt_buffer.clear(.{ 0.0, 0.0, 0.0, 1.0 }, 32);
try opt_buffer.drawTextBuffer(view, 0, 0);


// At x=5, showing chars 5-24: " x = function(y) { "
// Position 0: ' ' (source 5) - should be white
// Position 5: 'f' (source 10) - should be GREEN
Expand All @@ -2332,7 +2331,6 @@ test "drawTextBuffer - setStyledText with multiple colors and horizontal scrolli
try opt_buffer.clear(.{ 0.0, 0.0, 0.0, 1.0 }, 32);
try opt_buffer.drawTextBuffer(view, 0, 0);


// At x=15, showing chars 15-34: "ion(y) { return y * "
// "const x = function..."
// 0123456789012345678...
Expand Down Expand Up @@ -2371,7 +2369,6 @@ test "drawTextBuffer - setStyledText with multiple colors and horizontal scrolli
try opt_buffer.clear(.{ 0.0, 0.0, 0.0, 1.0 }, 32);
try opt_buffer.drawTextBuffer(view, 0, 0);


// At x=25, showing chars 25-44: "eturn y * 2; }"
// Position 0: 'e' (source 25) - should be BLUE (part of "return" 24-30)
// Position 4: 'n' (source 29) - should be BLUE
Expand Down Expand Up @@ -3187,3 +3184,80 @@ test "drawTextBuffer - wcwidth cursor movement matches rendered output" {
const cell_0_final = opt_buffer.get(0, 0) orelse unreachable;
try std.testing.expectEqual(@as(u32, ' '), cell_0_final.char);
}

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