native: stop highlights from spreading onto text appended at the line end (#1621)

A highlight that reached the end of its line also colored text that
later
grew the line. TextBuffer.append, EditBuffer inserts and undo, and a
line
that setText emptied and that then grew again all drew the new cells in
the
highlight's style, while getLineHighlights still reported the old range.
Styled text did the same when its chunks covered the whole line. The
cause
is that the last style span ended at the line width, and the renderer
keeps
the last span for every later column.

Span building now always ends a highlighted line with a default-style
span
that runs to the end of any line, so the spans depend only on the
highlights
and stay right when the line grows without a rebuild. The line width is
no
longer needed to build spans, so its computation goes away from the
callers.
The styled-text span builder closes its spans the same way.

A new native table compares the spans after append, insert, setText("")
followed by append, and replaceText("") followed by undo with a fresh
build of
the final text; it replaces two duplicated span tests. Dropping the
closing
span, shortening it, or reverting either builder each makes a test fail.

Fixes #1612
This commit is contained in:
Simon Klee authored and GitHub committed 2026-10-10 14:52:13 +02:00
1 parent e4eb894f00
commit 78976e0e2a
3 files changed
+70 -138

No files matched your search

@@ -10,6 +10,7 @@ const owned_styled = @import("owned-styled-text.zig");
const TextBuffer = text_buffer.UnifiedTextBuffer;
const Highlight = text_buffer.Highlight;
const EditBuffer = @import("../edit-buffer.zig").EditBuffer;
test "TextBuffer styled seek - JSON tokens retain every line span" {
var pools = TestPools.init(std.testing.allocator);
@@ -36,7 +37,8 @@ test "TextBuffer styled seek - JSON tokens retain every line span" {
const highlights = tb.getLineHighlights(row);
const spans = tb.line_spans.items[row].items;
try std.testing.expectEqual(5, highlights.len);
try std.testing.expectEqual(5, spans.len);
try std.testing.expectEqual(6, spans.len);
try std.testing.expectEqual(text_buffer.StyleSpan{ .col = 15, .style_id = 0, .next_col = std.math.maxInt(u32) }, spans[5]);
for ([_]u32{ 0, 2, 9, 11, 14 }, [_]u32{ 2, 9, 11, 14, 15 }, 0..) |start, end, token| {
try std.testing.expectEqual(start, highlights[token].col_start);
try std.testing.expectEqual(end, highlights[token].col_end);
@@ -101,7 +103,7 @@ test "TextBuffer styled seek - display widths multiline chunks and links" {
.{ .text = first ++ "\n\n" ++ emoji, .fg = fg, .bg = bg, .attributes = 3, .url = url },
.{ .text = "", .fg = fg, .bg = bg, .attributes = 3, .url = url },
.{ .text = "\n" ++ combining ++ "\n\tX", .fg = fg, .bg = bg, .attributes = 3, .url = url },
.{ .text = "\n\n", .fg = fg, .bg = bg, .attributes = 3, .url = url },
.{ .text = "\n\u{301}\n", .fg = fg, .bg = bg, .attributes = 3, .url = url },
});
const style = current.?.style;
try std.testing.expectEqual(9, tb.getLineCount());
@@ -120,9 +122,10 @@ test "TextBuffer styled seek - display widths multiline chunks and links" {
try std.testing.expectEqualStrings(url, try pools.links.get(link_id));
try std.testing.expectEqual(1, try pools.links.getRefcount(link_id));
}
const text = "\n\n" ++ first ++ "\n\n" ++ emoji ++ "\n" ++ combining ++ "\n\tX\n\n";
const text = "\n\n" ++ first ++ "\n\n" ++ emoji ++ "\n" ++ combining ++ "\n\tX\n\u{301}\n";
var output: [text.len]u8 = undefined;
try std.testing.expectEqualStrings(text, output[0..tb.getPlainTextIntoBuffer(&output)]);
try std.testing.expectEqual(0, tb.getLineSpans(7).len);
const offset = tb.measureText(first ++ emoji ++ combining);
try tb.addHighlightByCharRange(offset, offset + tb.measureText("\tX"), 99, 9, 42);
try std.testing.expectEqual(2, tb.getLineHighlights(6).len);
@@ -510,9 +513,9 @@ test "TextBuffer removal rejection - mixed refs preserve accepted highlights and
}
for (0..2) |line_idx| {
const actual = tb.getLineSpans(line_idx);
try std.testing.expectEqual(@as(usize, if (hl_ref == 0) 2 else 1), actual.len);
try std.testing.expectEqual(retained.style_id, actual[actual.len - 1].style_id);
try std.testing.expectEqual(@as(u32, 6), actual[actual.len - 1].next_col);
try std.testing.expectEqual(@as(usize, if (hl_ref == 0) 3 else 2), actual.len);
try std.testing.expectEqual(retained.style_id, actual[actual.len - 2].style_id);
try std.testing.expectEqual(text_buffer.StyleSpan{ .col = 6, .style_id = 0, .next_col = std.math.maxInt(u32) }, actual[actual.len - 1]);
}
if (succeeded) break;
}
@@ -729,55 +732,55 @@ test "TextBuffer highlights - integration with SyntaxStyle" {
try std.testing.expect(style.resolveById(comment_id) != null);
}
test "TextBuffer highlights - style spans computed correctly" {
// Each row highlights `line` of `text` with [start, end, style, priority] ranges, applies `edit` with `arg`,
// and expects [col, style, next_col] spans on `line`: the spans the final text gets from a fresh highlight.
test "TextBuffer highlights - spans after the line grows match a fresh build" {
var pools = TestPools.init(std.testing.allocator);
defer pools.deinit();
var tb = try TextBuffer.init(std.testing.allocator, &pools.graphemes, &pools.links, .unicode);
defer tb.deinit();
try tb.setText("0123456789");
try tb.addHighlight(0, 0, 3, 1, 1, 0);
try tb.addHighlight(0, 5, 8, 2, 1, 0);
const spans = tb.getLineSpans(0);
try std.testing.expect(spans.len > 0);
// Should have spans for: [0-3 style:1], [3-5 style:0/default], [5-8 style:2], ...
var found_style1 = false;
var found_style2 = false;
for (spans) |span| {
if (span.style_id == 1) found_style1 = true;
if (span.style_id == 2) found_style2 = true;
}
try std.testing.expect(found_style1);
try std.testing.expect(found_style2);
}
test "TextBuffer highlights - priority handling in spans" {
var pools = TestPools.init(std.testing.allocator);
defer pools.deinit();
var tb = try TextBuffer.init(std.testing.allocator, &pools.graphemes, &pools.links, .unicode);
defer tb.deinit();
try tb.setText("0123456789");
try tb.addHighlight(0, 0, 8, 1, 1, 0);
try tb.addHighlight(0, 3, 6, 2, 5, 0);
const spans = tb.getLineSpans(0);
try std.testing.expect(spans.len > 0);
// In range 3-6, style 2 should win due to higher priority
var found_high_priority = false;
for (spans) |span| {
if (span.col >= 3 and span.col < 6 and span.style_id == 2) {
found_high_priority = true;
const max = std.math.maxInt(u32);
const red: []const [4]u32 = &.{.{ 0, 3, 1, 1 }};
const red_spans: []const [3]u32 = &.{ .{ 0, 1, 3 }, .{ 3, 0, max } };
const Edit = enum { none, append, insert, clear_append, replace_undo };
for ([_]struct { []const u8, usize, []const [4]u32, Edit, []const u8, []const [3]u32 }{
.{ "0123456789", 0, &.{ .{ 0, 3, 1, 1 }, .{ 5, 8, 2, 1 } }, .none, "", &.{ .{ 0, 1, 3 }, .{ 3, 0, 5 }, .{ 5, 2, 8 }, .{ 8, 0, max } } },
.{ "0123456789", 0, &.{ .{ 0, 8, 1, 1 }, .{ 3, 6, 2, 5 } }, .none, "", &.{ .{ 0, 1, 3 }, .{ 3, 2, 6 }, .{ 6, 1, 8 }, .{ 8, 0, max } } },
.{ "xyz", 0, red, .append, "abc", red_spans },
.{ "xyzw", 0, red, .append, "ab", red_spans },
.{ "x", 0, red, .append, "yzabc", red_spans },
.{ "xyz\n", 1, red, .append, "abcdef", red_spans },
.{ "xyz", 0, red, .insert, "abc", red_spans },
.{ "first\nsecond", 0, red, .clear_append, "abcdef", red_spans },
.{ "first\nsecond", 0, red, .replace_undo, "", red_spans },
}) |row| {
const text, const line, const highlights, const edit, const arg, const expected = row;
const eb = try EditBuffer.init(std.testing.allocator, &pools.graphemes, &pools.links, .unicode, null);
defer eb.deinit();
const fresh = try TextBuffer.init(std.testing.allocator, &pools.graphemes, &pools.links, .unicode);
defer fresh.deinit();
try eb.setText(text);
for (highlights) |hl| try eb.tb.addHighlight(line, hl[0], hl[1], hl[2], @intCast(hl[3]), 0);
switch (edit) {
.none => {},
.append => try eb.tb.append(arg),
.insert => try eb.insertText(arg),
.clear_append => {
try eb.tb.setText("");
try eb.tb.append(arg);
},
.replace_undo => {
try eb.replaceText(arg);
_ = try eb.undo();
},
}
var output: [32]u8 = undefined;
try fresh.setText(output[0..eb.tb.getPlainTextIntoBuffer(&output)]);
for (highlights) |hl| try fresh.addHighlight(line, hl[0], hl[1], hl[2], @intCast(hl[3]), 0);
for ([_]*TextBuffer{ eb.tb, fresh }) |tb| {
const spans = tb.getLineSpans(line);
try std.testing.expectEqual(expected.len, spans.len);
for (expected, spans) |want, span| try std.testing.expectEqual(want, [3]u32{ span.col, span.style_id, span.next_col });
}
}
try std.testing.expect(found_high_priority);
}
// ===== Character Range Highlight Tests =====
@@ -2673,61 +2673,6 @@ test "TextBufferView highlights - overlapping highlights" {
try std.testing.expectEqual(@as(usize, 2), highlights.len);
}
test "TextBufferView highlights - style spans computed correctly" {
var pools = TestPools.init(std.testing.allocator);
defer pools.deinit();
var tb = try TextBuffer.init(std.testing.allocator, &pools.graphemes, &pools.links, .wcwidth);
defer tb.deinit();
var view = try TextBufferView.init(std.testing.allocator, tb);
defer view.deinit();
try tb.setText("0123456789");
try tb.addHighlight(0, 0, 3, 1, 1, 0);
try tb.addHighlight(0, 5, 8, 2, 1, 0);
const spans = tb.getLineSpans(0);
try std.testing.expect(spans.len > 0);
var found_style1 = false;
var found_style2 = false;
for (spans) |span| {
if (span.style_id == 1) found_style1 = true;
if (span.style_id == 2) found_style2 = true;
}
try std.testing.expect(found_style1);
try std.testing.expect(found_style2);
}
test "TextBufferView highlights - priority handling in spans" {
var pools = TestPools.init(std.testing.allocator);
defer pools.deinit();
var tb = try TextBuffer.init(std.testing.allocator, &pools.graphemes, &pools.links, .wcwidth);
defer tb.deinit();
var view = try TextBufferView.init(std.testing.allocator, tb);
defer view.deinit();
try tb.setText("0123456789");
try tb.addHighlight(0, 0, 8, 1, 1, 0);
try tb.addHighlight(0, 3, 6, 2, 5, 0);
const spans = tb.getLineSpans(0);
try std.testing.expect(spans.len > 0);
var found_high_priority = false;
for (spans) |span| {
if (span.col >= 3 and span.col < 6 and span.style_id == 2) {
found_high_priority = true;
}
}
try std.testing.expect(found_high_priority);
}
test "TextBufferView char range highlights - single line highlight" {
var pools = TestPools.init(std.testing.allocator);
defer pools.deinit();
+16 -32
View File
@@ -867,10 +867,10 @@ pub const UnifiedTextBuffer = struct {
remaining_cols -= width;
col += width;
}
if (col > 0 and col < line.width_cols) {
if (col > 0) {
try spans.items[line_index].append(self.global_allocator, .{
.col = col,
.next_col = line.width_cols,
.next_col = std.math.maxInt(u32),
.style_id = 0,
});
}
@@ -932,21 +932,13 @@ pub const UnifiedTextBuffer = struct {
const line_count = if (self.getHighlightCount() == 0) 0 else @min(self.line_highlights.items.len, candidate.root.metrics().custom.linestart_count);
try spans.ensureTotalCapacity(self.global_allocator, line_count);
try highlights.ensureTotalCapacity(self.global_allocator, line_count);
var segment_index: usize = 0;
for (self.line_highlights.items[0..line_count], 0..) |list, line_idx| {
var line_width: u32 = 0;
while (segment_index < result.segments.items.len) {
const segment = result.segments.items[segment_index];
segment_index += 1;
if (segment.isBreak()) break;
if (segment.asText()) |chunk| line_width += chunk.width_cols;
}
highlights.appendAssumeCapacity(.empty);
for (list.items) |hl| {
if (!hl.internal) try highlights.items[line_idx].append(self.global_allocator, hl);
}
spans.appendAssumeCapacity(.empty);
try self.buildLineSpans(highlights.items[line_idx].items, line_width, &spans.items[line_idx]);
try self.buildLineSpans(highlights.items[line_idx].items, &spans.items[line_idx]);
}
// History nodes are shared: publish undo on the live rope, after all preparation.
@@ -1310,11 +1302,7 @@ pub const UnifiedTextBuffer = struct {
// Stage in spare capacity without publishing the new length or copying accepted highlights.
highlights.unusedCapacitySlice()[0] = hl;
if (self.highlight_batch_depth == 0) {
try self.buildLineSpans(
highlights.allocatedSlice()[0 .. highlights.items.len + 1],
self.lineWidthAt(@intCast(line_idx)),
&addition.spans,
);
try self.buildLineSpans(highlights.allocatedSlice()[0 .. highlights.items.len + 1], &addition.spans);
}
return addition;
}
@@ -1402,12 +1390,10 @@ pub const UnifiedTextBuffer = struct {
return TextBufferError.InvalidIndex;
}
const highlights = self.getLineHighlights(line_idx);
const line_width = if (highlights.len == 0) 0 else self.lineWidthAt(@intCast(line_idx));
try self.buildLineSpans(highlights, line_width, &self.line_spans.items[line_idx]);
try self.buildLineSpans(self.getLineHighlights(line_idx), &self.line_spans.items[line_idx]);
}
fn buildLineSpans(self: *const Self, highlights: []const Highlight, line_width: u32, spans: *std.ArrayListUnmanaged(StyleSpan)) TextBufferError!void {
fn buildLineSpans(self: *const Self, highlights: []const Highlight, spans: *std.ArrayListUnmanaged(StyleSpan)) TextBufferError!void {
spans.clearRetainingCapacity();
// Collect all boundary columns
const Event = struct {
@@ -1472,16 +1458,15 @@ pub const UnifiedTextBuffer = struct {
}
}
// Emit final span after last event if there were any highlights
// This ensures the line returns to default styling after the last highlight ends
if (events.items.len > 0 and active.count() == 0) {
if (current_col < line_width) {
try spans.append(self.global_allocator, .{
.col = current_col,
.style_id = 0, // No style (default)
.next_col = line_width,
});
}
// The default style runs from the last highlight end to any later column, so text that
// grows the line without a rebuild (append, edits, undo) stays unstyled.
std.debug.assert(active.count() == 0);
if (events.items.len > 0) {
try spans.append(self.global_allocator, .{
.col = current_col,
.style_id = 0,
.next_col = std.math.maxInt(u32),
});
}
}
@@ -1593,8 +1578,7 @@ pub const UnifiedTextBuffer = struct {
for (hl_list.items) |hl| {
if (hl.hl_ref != hl_ref) try retained.append(self.global_allocator, hl);
}
const line_width = if (retained.items.len == 0) 0 else self.lineWidthAt(@intCast(line_idx));
try self.buildLineSpans(retained.items, line_width, &removals.items[removals.items.len - 1].spans);
try self.buildLineSpans(retained.items, &removals.items[removals.items.len - 1].spans);
}
}
if (self.highlight_batch_depth > 0) {