From 0021d8c7676834227a8b5cae11d435a343ec7350 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 30 Jul 2026 15:08:33 +0200 Subject: [PATCH 1/2] text-buffer: widen chunk width past u16 Lines longer than 65535 columns saturated chunk width at u16 max, so cursor, viewport, and rope weight drifted. Store width as u32 so horizontal extent stays accurate for very long lines. --- .../renderables/EditBufferRenderable.test.ts | 25 ++++++++++++++++ .../core/src/zig/tests/text-buffer_test.zig | 30 +++++++++++++++++-- packages/core/src/zig/text-buffer-segment.zig | 2 +- packages/core/src/zig/text-buffer.zig | 2 +- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/core/src/renderables/EditBufferRenderable.test.ts b/packages/core/src/renderables/EditBufferRenderable.test.ts index 6a063075c4..995689f306 100644 --- a/packages/core/src/renderables/EditBufferRenderable.test.ts +++ b/packages/core/src/renderables/EditBufferRenderable.test.ts @@ -227,6 +227,31 @@ describe("EditBufferRenderable", () => { expect(textarea.cursorOffset).toBe(6) }) + test("tracks the cursor and viewport past 65535 columns", async () => { + const textarea = new TextareaRenderable(renderer, { + width: 20, + height: 3, + wrapMode: "none", + }) + + renderer.root.add(textarea) + await renderOnce() + + for (const length of [65535, 65536, 70000]) { + textarea.clear() + textarea.insertText("x".repeat(length)) + await renderOnce() + + expect(textarea.plainText).toHaveLength(length) + expect(textarea.logicalCursor).toMatchObject({ row: 0, col: length, offset: length }) + expect(textarea.visualCursor.offset).toBe(length) + + const viewport = textarea.editorView.getViewport() + expect(viewport.offsetX).toBeLessThanOrEqual(length) + expect(viewport.offsetX + viewport.width).toBeGreaterThan(length) + } + }) + test("goes to exact current line boundaries through renderable api", async () => { const textarea = new TextareaRenderable(renderer, { width: 20, diff --git a/packages/core/src/zig/tests/text-buffer_test.zig b/packages/core/src/zig/tests/text-buffer_test.zig index 16714ce97e..0be5e0806b 100644 --- a/packages/core/src/zig/tests/text-buffer_test.zig +++ b/packages/core/src/zig/tests/text-buffer_test.zig @@ -1559,8 +1559,34 @@ test "TextBuffer setText - CRLF at SIMD boundary" { try std.testing.expectEqual(expected_len, written); } -test "TextBuffer setText - line with multiple u16-sized chunks (SKIPPED)" { - return error.SkipZigTest; +test "TextBuffer setText - line width past u16 maximum" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .unicode); + defer tb.deinit(); + + for ([_]u32{ 65535, 65536, 70000 }) |length| { + const text = try std.testing.allocator.alloc(u8, length); + defer std.testing.allocator.free(text); + @memset(text, 'x'); + + try tb.setText(text); + + try std.testing.expectEqual(length, tb.getLength()); + try std.testing.expectEqual(length, iter_mod.lineWidthAt(tb.rope(), 0)); + try std.testing.expectEqual(length, tb.rope().totalWeight()); + try std.testing.expectEqual(length, iter_mod.coordsToOffset(tb.rope(), 0, length).?); + try std.testing.expectEqual(iter_mod.Coords{ .row = 0, .col = length }, iter_mod.offsetToCoords(tb.rope(), length).?); + + const output = try std.testing.allocator.alloc(u8, length); + defer std.testing.allocator.free(output); + const written = tb.getPlainTextIntoBuffer(output); + try std.testing.expectEqual(@as(usize, length), written); + try std.testing.expectEqualSlices(u8, text, output); + } } test "TextBuffer setText - validate rope structure is correct" { diff --git a/packages/core/src/zig/text-buffer-segment.zig b/packages/core/src/zig/text-buffer-segment.zig index 220edfc177..c3e300de28 100644 --- a/packages/core/src/zig/text-buffer-segment.zig +++ b/packages/core/src/zig/text-buffer-segment.zig @@ -37,7 +37,7 @@ pub const TextChunk = struct { mem_id: u8, byte_start: u32, byte_end: u32, - width: u16, + width: u32, flags: u8 = 0, graphemes: ?[]GraphemeInfo = null, wrap_offsets: ?[]utf8.WrapBreak = null, diff --git a/packages/core/src/zig/text-buffer.zig b/packages/core/src/zig/text-buffer.zig index cef075d554..e01fde27db 100644 --- a/packages/core/src/zig/text-buffer.zig +++ b/packages/core/src/zig/text-buffer.zig @@ -524,7 +524,7 @@ pub const UnifiedTextBuffer = struct { flags |= TextChunk.Flags.ASCII_ONLY; } - const chunk_width: u16 = @intCast(@min(65535, utf8.calculateTextWidth(chunk_bytes, self.tab_width, is_ascii, self.width_method))); + const chunk_width = utf8.calculateTextWidth(chunk_bytes, self.tab_width, is_ascii, self.width_method); return .{ .mem_id = mem_id, From dad175d315922442b606b1b2d413abc742461c92 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 30 Jul 2026 18:32:07 +0200 Subject: [PATCH 2/2] text-buffer: drop leftover u16 width clamps Chunk width is already u32 after the widen. Remove bench clamps and narrow casts that still assumed u16, and keep a single long-line regression case past the old limit. --- .../renderables/EditBufferRenderable.test.ts | 23 +++++++-------- .../zig/bench/text-chunk-graphemes_bench.zig | 8 ++--- .../core/src/zig/tests/text-buffer_test.zig | 29 +++++++------------ packages/core/src/zig/text-buffer-view.zig | 2 +- packages/core/src/zig/text-buffer.zig | 4 +-- 5 files changed, 24 insertions(+), 42 deletions(-) diff --git a/packages/core/src/renderables/EditBufferRenderable.test.ts b/packages/core/src/renderables/EditBufferRenderable.test.ts index 995689f306..7e650c9341 100644 --- a/packages/core/src/renderables/EditBufferRenderable.test.ts +++ b/packages/core/src/renderables/EditBufferRenderable.test.ts @@ -237,19 +237,16 @@ describe("EditBufferRenderable", () => { renderer.root.add(textarea) await renderOnce() - for (const length of [65535, 65536, 70000]) { - textarea.clear() - textarea.insertText("x".repeat(length)) - await renderOnce() - - expect(textarea.plainText).toHaveLength(length) - expect(textarea.logicalCursor).toMatchObject({ row: 0, col: length, offset: length }) - expect(textarea.visualCursor.offset).toBe(length) - - const viewport = textarea.editorView.getViewport() - expect(viewport.offsetX).toBeLessThanOrEqual(length) - expect(viewport.offsetX + viewport.width).toBeGreaterThan(length) - } + const length = 70000 + textarea.insertText("x".repeat(length)) + await renderOnce() + + expect(textarea.logicalCursor).toMatchObject({ row: 0, col: length, offset: length }) + expect(textarea.visualCursor.offset).toBe(length) + + const viewport = textarea.editorView.getViewport() + expect(viewport.offsetX).toBeLessThanOrEqual(length) + expect(viewport.offsetX + viewport.width).toBeGreaterThan(length) }) test("goes to exact current line boundaries through renderable api", async () => { diff --git a/packages/core/src/zig/bench/text-chunk-graphemes_bench.zig b/packages/core/src/zig/bench/text-chunk-graphemes_bench.zig index 6bd39f8079..a1e6b83b84 100644 --- a/packages/core/src/zig/bench/text-chunk-graphemes_bench.zig +++ b/packages/core/src/zig/bench/text-chunk-graphemes_bench.zig @@ -99,14 +99,11 @@ fn benchGetGraphemes( else => false, }; - // Create TextChunk - // Width is approximate - clamped to u16 max - const approx_width: u16 = @intCast(@min(text.len, std.math.maxInt(u16))); var chunk: TextChunk = .{ .mem_id = mem_id, .byte_start = 0, .byte_end = @intCast(text.len), - .width = approx_width, + .width = @intCast(text.len), .flags = if (is_ascii) TextChunk.Flags.ASCII_ONLY else 0, }; @@ -186,12 +183,11 @@ fn computeBenchName(allocator: std.mem.Allocator, size: usize, text_type: TextTy .ascii => true, else => false, }; - const approx_width: u16 = @intCast(@min(text.len, std.math.maxInt(u16))); var chunk: TextChunk = .{ .mem_id = mem_id, .byte_start = 0, .byte_end = @intCast(text.len), - .width = approx_width, + .width = @intCast(text.len), .flags = if (is_ascii) TextChunk.Flags.ASCII_ONLY else 0, }; diff --git a/packages/core/src/zig/tests/text-buffer_test.zig b/packages/core/src/zig/tests/text-buffer_test.zig index 0be5e0806b..aa677a7cef 100644 --- a/packages/core/src/zig/tests/text-buffer_test.zig +++ b/packages/core/src/zig/tests/text-buffer_test.zig @@ -1568,25 +1568,16 @@ test "TextBuffer setText - line width past u16 maximum" { var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .unicode); defer tb.deinit(); - for ([_]u32{ 65535, 65536, 70000 }) |length| { - const text = try std.testing.allocator.alloc(u8, length); - defer std.testing.allocator.free(text); - @memset(text, 'x'); - - try tb.setText(text); - - try std.testing.expectEqual(length, tb.getLength()); - try std.testing.expectEqual(length, iter_mod.lineWidthAt(tb.rope(), 0)); - try std.testing.expectEqual(length, tb.rope().totalWeight()); - try std.testing.expectEqual(length, iter_mod.coordsToOffset(tb.rope(), 0, length).?); - try std.testing.expectEqual(iter_mod.Coords{ .row = 0, .col = length }, iter_mod.offsetToCoords(tb.rope(), length).?); - - const output = try std.testing.allocator.alloc(u8, length); - defer std.testing.allocator.free(output); - const written = tb.getPlainTextIntoBuffer(output); - try std.testing.expectEqual(@as(usize, length), written); - try std.testing.expectEqualSlices(u8, text, output); - } + const length: u32 = std.math.maxInt(u16) + 1; + const text = try std.testing.allocator.alloc(u8, length); + defer std.testing.allocator.free(text); + @memset(text, 'x'); + + try tb.setText(text); + + try std.testing.expectEqual(length, iter_mod.lineWidthAt(tb.rope(), 0)); + try std.testing.expectEqual(length, iter_mod.coordsToOffset(tb.rope(), 0, length).?); + try std.testing.expectEqual(iter_mod.Coords{ .row = 0, .col = length }, iter_mod.offsetToCoords(tb.rope(), length).?); } test "TextBuffer setText - validate rope structure is correct" { diff --git a/packages/core/src/zig/text-buffer-view.zig b/packages/core/src/zig/text-buffer-view.zig index af5b365a8d..26296c9628 100644 --- a/packages/core/src/zig/text-buffer-view.zig +++ b/packages/core/src/zig/text-buffer-view.zig @@ -301,7 +301,7 @@ pub const UnifiedTextBufferView = struct { fn calculateChunkFitWord(self: *const Self, chunk: *const TextChunk, char_offset_in_chunk: u32, max_width: u32) tb.ChunkFitResult { if (max_width == 0) return .{ .char_count = 0, .width = 0 }; - const total_width = @as(u32, chunk.width) - char_offset_in_chunk; + const total_width = chunk.width - char_offset_in_chunk; if (total_width == 0) return .{ .char_count = 0, .width = 0 }; if (total_width <= max_width) return .{ .char_count = total_width, .width = total_width }; diff --git a/packages/core/src/zig/text-buffer.zig b/packages/core/src/zig/text-buffer.zig index e01fde27db..284ce71593 100644 --- a/packages/core/src/zig/text-buffer.zig +++ b/packages/core/src/zig/text-buffer.zig @@ -524,13 +524,11 @@ pub const UnifiedTextBuffer = struct { flags |= TextChunk.Flags.ASCII_ONLY; } - const chunk_width = utf8.calculateTextWidth(chunk_bytes, self.tab_width, is_ascii, self.width_method); - return .{ .mem_id = mem_id, .byte_start = byte_start, .byte_end = byte_end, - .width = chunk_width, + .width = utf8.calculateTextWidth(chunk_bytes, self.tab_width, is_ascii, self.width_method), .flags = flags, }; }