Skip to content
Merged
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
64 changes: 64 additions & 0 deletions packages/core/src/text-buffer-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,5 +637,69 @@ describe("TextBufferView", () => {
expect(result!.lineCount).toBe(4)
expect(result!.maxWidth).toBe(10)
})

it("should cache measure results for same width", () => {
const styledText = stringToStyledText("ABCDEFGHIJKLMNOPQRST")
buffer.setStyledText(styledText)

view.setWrapMode("char")

// First call - cache miss
const result1 = view.measureForDimensions(10, 10)
expect(result1).not.toBeNull()
expect(result1!.lineCount).toBe(2)

// Second call with same width - should return cached result
const result2 = view.measureForDimensions(10, 10)
expect(result2).not.toBeNull()
expect(result2!.lineCount).toBe(2)
expect(result2!.maxWidth).toBe(result1!.maxWidth)
})

it("should invalidate cache when content changes", () => {
const styledText1 = stringToStyledText("ABCDEFGHIJ")
buffer.setStyledText(styledText1)

view.setWrapMode("char")

// Measure with width 5 - should be 2 lines
const result1 = view.measureForDimensions(5, 10)
expect(result1!.lineCount).toBe(2)

// Change content to be longer
const styledText2 = stringToStyledText("ABCDEFGHIJKLMNOPQRST")
buffer.setStyledText(styledText2)

// Same width should now return different result
const result2 = view.measureForDimensions(5, 10)
expect(result2!.lineCount).toBe(4)
})

it("should invalidate cache when wrap mode changes", () => {
const styledText = stringToStyledText("Hello world test string here")
buffer.setStyledText(styledText)

view.setWrapMode("word")
const resultWord = view.measureForDimensions(10, 10)

view.setWrapMode("char")
const resultChar = view.measureForDimensions(10, 10)

// Word and char wrap should produce different results
expect(resultWord!.lineCount).not.toBe(resultChar!.lineCount)
})

it("should handle width 0 for intrinsic measurement", () => {
const styledText = stringToStyledText("Hello World")
buffer.setStyledText(styledText)

view.setWrapMode("word")

// Width 0 means get intrinsic width (no wrapping)
const result = view.measureForDimensions(0, 10)
expect(result).not.toBeNull()
expect(result!.lineCount).toBe(1)
expect(result!.maxWidth).toBe(11) // "Hello World" = 11 chars
})
})
})
102 changes: 102 additions & 0 deletions packages/core/src/zig/tests/text-buffer-view_test.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const std = @import("std");
const text_buffer = @import("../text-buffer.zig");
const iter_mod = @import("../text-buffer-iterators.zig");
const text_buffer_view = @import("../text-buffer-view.zig");
const gp = @import("../grapheme.zig");

Expand Down Expand Up @@ -1915,6 +1916,107 @@ test "TextBufferView measureForDimensions - does not modify cache" {
try std.testing.expectEqual(@as(u32, 1), actual_count);
}

test "TextBufferView measureForDimensions - cache invalidates after updateVirtualLines" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();

var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth);
defer tb.deinit();

var view = try TextBufferView.init(std.testing.allocator, tb);
defer view.deinit();

try tb.setText("AAAAA");
view.setWrapMode(.char);
view.setWrapWidth(5);

const result1 = try view.measureForDimensions(5, 10);
try std.testing.expectEqual(@as(u32, 1), result1.line_count);
try std.testing.expectEqual(@as(u32, 5), result1.max_width);

try tb.setText("AAAAAAAAAA");

// This clears the dirty flag, which would cause a false cache hit
// if we keyed on dirty instead of epoch.
_ = view.getVirtualLineCount();

const result2 = try view.measureForDimensions(5, 10);
try std.testing.expectEqual(@as(u32, 2), result2.line_count);
try std.testing.expectEqual(@as(u32, 5), result2.max_width);
}

test "TextBufferView measureForDimensions - width 0 uses intrinsic line widths" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();

var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth);
defer tb.deinit();

var view = try TextBufferView.init(std.testing.allocator, tb);
defer view.deinit();

try tb.setText("abc\ndefghij");
view.setWrapMode(.char);

const result = try view.measureForDimensions(0, 24);
try std.testing.expectEqual(tb.getLineCount(), result.line_count);
try std.testing.expectEqual(iter_mod.getMaxLineWidth(&tb.rope), result.max_width);
}

test "TextBufferView measureForDimensions - no wrap matches multi-segment line widths" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();

var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth);
defer tb.deinit();

var view = try TextBufferView.init(std.testing.allocator, tb);
defer view.deinit();

try tb.setText("AAAA");
try tb.append("BBBB");
view.setWrapMode(.none);

const line_info = view.getCachedLineInfo();
var expected_max: u32 = 0;
for (line_info.widths) |w| {
expected_max = @max(expected_max, w);
}

const result = try view.measureForDimensions(80, 24);
try std.testing.expectEqual(expected_max, result.max_width);
try std.testing.expectEqual(@as(u32, @intCast(line_info.widths.len)), result.line_count);
}

test "TextBufferView measureForDimensions - cache invalidates on switchToBuffer" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();

var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth);
defer tb.deinit();

var other_tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth);
defer other_tb.deinit();

var view = try TextBufferView.init(std.testing.allocator, tb);
defer view.deinit();

try tb.setText("AAAAAA");
view.setWrapMode(.char);

const result1 = try view.measureForDimensions(10, 10);
try std.testing.expectEqual(@as(u32, 6), result1.max_width);

try other_tb.setText("BBBBBBBBBB");
try std.testing.expectEqual(tb.getContentEpoch(), other_tb.getContentEpoch());

view.switchToBuffer(other_tb);

const result2 = try view.measureForDimensions(10, 10);
try std.testing.expectEqual(@as(u32, 10), result2.max_width);
try std.testing.expectEqual(@as(u32, 1), result2.line_count);
}

test "TextBufferView measureForDimensions - char wrap" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/zig/tests/wrap-cache-perf_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ test "word wrap complexity - width changes are O(n)" {
_ = view.getVirtualLineCount();

for (widths, 0..) |width, i| {
times[i] = measureMedianViewUpdate(view, width, 3);
times[i] = measureMedianViewUpdate(view, width, 5);
}

var min_time: u64 = std.math.maxInt(u64);
Expand All @@ -61,8 +61,10 @@ test "word wrap complexity - width changes are O(n)" {

const ratio = @as(f64, @floatFromInt(max_time)) / @as(f64, @floatFromInt(min_time));

// All times should be roughly similar (within 3x) since text size is constant
try std.testing.expect(ratio < 3.0);
// All times should be roughly similar since text size is constant.
// Using 5x threshold to accommodate CI runner variability while still
// catching O(n^2) regressions (which would show 10-100x differences).
try std.testing.expect(ratio < 5.0);
}

test "word wrap - virtual line count correctness" {
Expand Down
76 changes: 68 additions & 8 deletions packages/core/src/zig/text-buffer-view.zig
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,22 @@ pub const UnifiedTextBufferView = struct {
cached_line_vline_counts: std.ArrayListUnmanaged(u32),
global_allocator: Allocator,
virtual_lines_arena: *std.heap.ArenaAllocator,

/// Persistent arena for measureForDimensions. Each call resets it with
/// retain_capacity to avoid mmap/munmap churn during streaming.
measure_arena: std.heap.ArenaAllocator,
tab_indicator: ?u32,
tab_indicator_color: ?RGBA,

// Measurement cache for Yoga layout. Keyed by (buffer, epoch, width, wrap_mode).
// Using epoch instead of dirty flag prevents stale returns when unrelated
// code paths clear dirty (e.g., updateVirtualLines).
cached_measure_width: ?u32,
cached_measure_wrap_mode: WrapMode,
cached_measure_result: ?MeasureResult,
cached_measure_epoch: u64,
cached_measure_buffer: ?*UnifiedTextBuffer,

pub fn init(global_allocator: Allocator, text_buffer: *UnifiedTextBuffer) TextBufferViewError!*Self {
const self = global_allocator.create(Self) catch return TextBufferViewError.OutOfMemory;
errdefer global_allocator.destroy(self);
Expand Down Expand Up @@ -156,8 +169,14 @@ pub const UnifiedTextBufferView = struct {
.cached_line_vline_counts = .{},
.global_allocator = global_allocator,
.virtual_lines_arena = virtual_lines_internal_arena,
.measure_arena = std.heap.ArenaAllocator.init(global_allocator),
.tab_indicator = null,
.tab_indicator_color = null,
.cached_measure_width = null,
.cached_measure_wrap_mode = .none,
.cached_measure_result = null,
.cached_measure_epoch = 0,
.cached_measure_buffer = null,
};

return self;
Expand All @@ -170,6 +189,7 @@ pub const UnifiedTextBufferView = struct {
self.original_text_buffer.unregisterView(self.view_id);
self.virtual_lines_arena.deinit();
self.global_allocator.destroy(self.virtual_lines_arena);
self.measure_arena.deinit();
self.global_allocator.destroy(self);
}

Expand Down Expand Up @@ -690,14 +710,47 @@ pub const UnifiedTextBufferView = struct {

/// Measure dimensions for given width/height WITHOUT modifying virtual lines cache
/// This is useful for Yoga measure functions that need to know dimensions without committing changes
/// Special case: width=0 means "measure intrinsic/max-content width" (no wrapping)
pub fn measureForDimensions(self: *const Self, width: u32, height: u32) TextBufferViewError!MeasureResult {
/// Special case: width=0 or wrap_mode=.none means "measure intrinsic/max-content width" (no wrapping)
pub fn measureForDimensions(self: *Self, width: u32, height: u32) TextBufferViewError!MeasureResult {
_ = height; // Height is for future use, currently only width affects layout

// Create temporary arena for measurement
var measure_arena = std.heap.ArenaAllocator.init(self.global_allocator);
defer measure_arena.deinit();
const measure_allocator = measure_arena.allocator();
const epoch = self.text_buffer.getContentEpoch();
if (self.cached_measure_result) |result| {
if (self.cached_measure_epoch == epoch and self.cached_measure_buffer == self.text_buffer) {
if (self.cached_measure_width) |cached_width| {
if (cached_width == width and self.cached_measure_wrap_mode == self.wrap_mode) {
return result;
}
}
}
}

// No-wrap path avoids allocations by using marker-based line widths.
if (width == 0 or self.wrap_mode == .none) {
const line_count = self.text_buffer.getLineCount();
var max_width: u32 = 0;
var row: u32 = 0;
while (row < line_count) : (row += 1) {
max_width = @max(max_width, iter_mod.lineWidthAt(&self.text_buffer.rope, row));
}

const result = MeasureResult{
.line_count = line_count,
.max_width = max_width,
};

self.cached_measure_width = width;
self.cached_measure_wrap_mode = self.wrap_mode;
self.cached_measure_result = result;
self.cached_measure_epoch = epoch;
self.cached_measure_buffer = self.text_buffer;

return result;
}

// Reuse arena capacity to avoid allocation overhead during streaming.
_ = self.measure_arena.reset(.retain_capacity);
const measure_allocator = self.measure_arena.allocator();

// Create temporary output structures
var temp_virtual_lines = std.ArrayListUnmanaged(VirtualLine){};
Expand All @@ -719,7 +772,6 @@ pub const UnifiedTextBufferView = struct {
};

// Use width for wrap calculation
// Special case: width=0 means get intrinsic width (no wrapping), so pass null
const wrap_width_for_measure = if (self.wrap_mode != .none and width > 0) width else null;

// Call generic calculation with temporary structures
Expand All @@ -737,10 +789,18 @@ pub const UnifiedTextBufferView = struct {
max_width = @max(max_width, w);
}

return MeasureResult{
const result = MeasureResult{
.line_count = @intCast(temp_virtual_lines.items.len),
.max_width = max_width,
};

self.cached_measure_width = width;
self.cached_measure_wrap_mode = self.wrap_mode;
self.cached_measure_result = result;
self.cached_measure_epoch = epoch;
self.cached_measure_buffer = self.text_buffer;

return result;
}

/// Generic virtual line calculation that writes to provided output structures
Expand Down
23 changes: 21 additions & 2 deletions packages/core/src/zig/text-buffer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ pub const UnifiedTextBuffer = struct {
next_view_id: u32,
free_view_ids: std.ArrayListUnmanaged(u32),

/// Monotonic counter that increments on every content change. Views use this
/// to detect stale caches even after clearViewDirty() runs.
content_epoch: u64,

// Per-line highlight cache (invalidated on edits)
// Maps line_idx to highlights for that line
line_highlights: std.ArrayListUnmanaged(std.ArrayListUnmanaged(Highlight)),
Expand Down Expand Up @@ -118,6 +122,7 @@ pub const UnifiedTextBuffer = struct {
.view_dirty_flags = view_dirty_flags,
.next_view_id = 0,
.free_view_ids = free_view_ids,
.content_epoch = 0,
.line_highlights = .{},
.line_spans = .{},
.highlight_batch_depth = 0,
Expand Down Expand Up @@ -198,7 +203,16 @@ pub const UnifiedTextBuffer = struct {
}
}

/// Returns the current content epoch. Use this to detect buffer changes
/// independent of the dirty flag (other code paths may clear dirty).
pub fn getContentEpoch(self: *const Self) u64 {
return self.content_epoch;
}

fn markAllViewsDirty(self: *Self) void {
// Increment epoch first so views see the new value when checking caches.
// Use wrapping add for safety, though u64 won't overflow in practice.
self.content_epoch +%= 1;
for (self.view_dirty_flags.items) |*flag| {
flag.* = true;
}
Expand Down Expand Up @@ -972,10 +986,15 @@ pub const UnifiedTextBuffer = struct {
return self.tab_width;
}

/// Set tab width (will be rounded up to nearest multiple of 2)
/// Set tab width, rounding up to nearest multiple of 2 (minimum 2).
/// Marks all views dirty if the width actually changes, since tab width
/// affects measured line widths and virtual line calculations.
pub fn setTabWidth(self: *Self, width: u8) void {
const clamped_width = @max(2, width);
self.tab_width = if (clamped_width % 2 == 0) clamped_width else clamped_width + 1;
const new_width = if (clamped_width % 2 == 0) clamped_width else clamped_width + 1;
if (self.tab_width == new_width) return;
self.tab_width = new_width;
self.markAllViewsDirty();
}

/// Debug log the rope structure using rope.toText
Expand Down
Loading
Loading