native: check highlight counts after shrinking setText (#1601)

native: drop highlights on removed lines when replacing text
setText kept user highlights on every stored line, also on lines past
the
new line count. getHighlightCount and getLineHighlights reported them,
and
text that grew back into such a line (append) was colored by them.

setPlainText now builds the kept highlight lists during preparation, for
lines below the new line count only, and swaps them in when it publishes
the text, like the styled-text path. The lists past the new end are
freed
with the old storage. This replaces the in-place internal-highlight
filter.
The fix covers setText, setTextFromMemId*, setTextFromMemRangeWithUndo
(EditBuffer.replaceText), and replaceText (EditBuffer.setText).

Source additions (net -5 lines): the staged highlight lists and their
cleanup hold the kept highlights until publication, and one comment
states
the line-count bound.

Fixes https://github.com/anomalyco/opentui/issues/1588
This commit is contained in:
Simon Klee authored and GitHub committed 2026-10-09 21:33:25 +02:00
1 parent 07225659bb
commit acf13584be
3 files changed
+37 -44

No files matched your search

+24 -27
View File
@@ -4,6 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { TextBuffer } from "./text-buffer.js"
import { EditBuffer } from "./edit-buffer.js"
import { resolveRenderLib } from "./zig.js"
import { StyledText, stringToStyledText } from "./lib/styled-text.js"
import { RGBA } from "./lib/RGBA.js"
@@ -193,36 +194,32 @@ describe("TextBuffer", () => {
expect(buffer.getSyntaxStyle()).toBeNull()
})
it("setText clears styled text chunk highlights", () => {
// Replacement drops styled chunk highlights (line -1: the old text is styled) and keeps user highlights
// only on lines that still exist. Expected: the hlRefs left on lines 0 and 1.
it.each<[string, string, number, string, number[]]>([
["TextBuffer.setText", "Styled", -1, "Plain", []],
["TextBuffer.setText", "Hello World", 0, "New Text", [65535]],
["TextBuffer.setText", "first\nsecond", 1, "xyz", []],
["EditBuffer.setText", "first\nsecond", 1, "xyz", []],
["EditBuffer.replaceText", "first\nsecond", 1, "xyz", []],
["EditBuffer.replaceText", "a\nb\nc", 1, "x\ny", [65535]],
])("%s from %j with a highlight on line %d to %j keeps %j", (operation, before, line, after, expected) => {
const syntaxStyle = SyntaxStyle.create(resourceContext)
buffer.setSyntaxStyle(syntaxStyle)
buffer.setStyledText(new StyledText([{ __isChunk: true, text: "Styled", fg: RGBA.fromValues(1, 0, 0, 1) }]))
const fg = RGBA.fromValues(0, 1, 0, 1)
const target = operation === "TextBuffer.setText" ? buffer : EditBuffer.create("wcwidth", resourceContext)
target.setSyntaxStyle(syntaxStyle)
if (line < 0) buffer.setStyledText(new StyledText([{ __isChunk: true, text: before, fg }]))
else target.setText(before)
const styleId = syntaxStyle.registerStyle("user-highlight", { fg })
if (line >= 0) target.addHighlight(line, { start: 0, end: 1, styleId, priority: 0, hlRef: 65535 })
expect([0, 1].flatMap((i) => target.getLineHighlights(i)).length).toBe(1)
expect(buffer.getHighlightCount()).toBe(1)
buffer.setText("Plain")
expect(buffer.getPlainText()).toBe("Plain")
expect(buffer.getHighlightCount()).toBe(0)
syntaxStyle.destroy()
})
it("setText preserves user highlights including max hlRef", () => {
const syntaxStyle = SyntaxStyle.create(resourceContext)
const styleId = syntaxStyle.registerStyle("user-highlight", { fg: RGBA.fromValues(0, 1, 0, 1) })
buffer.setSyntaxStyle(syntaxStyle)
buffer.setText("Hello World")
buffer.addHighlight(0, { start: 0, end: 5, styleId, priority: 0, hlRef: 65535 })
expect(buffer.getHighlightCount()).toBe(1)
buffer.setText("New Text")
expect(buffer.getPlainText()).toBe("New Text")
expect(buffer.getHighlightCount()).toBe(1)
expect(buffer.getLineHighlights(0)[0]?.hlRef).toBe(65535)
if (operation === "EditBuffer.replaceText") (target as EditBuffer).replaceText(after)
else target.setText(after)
expect([0, 1].flatMap((i) => target.getLineHighlights(i).map((hl) => hl.hlRef))).toEqual(expected)
if (target === buffer) expect(buffer.getHighlightCount()).toBe(expected.length)
if (target !== buffer) target.destroy()
syntaxStyle.destroy()
})
})
@@ -1688,6 +1688,7 @@ fn checkPlainTextAllocationFailures() !void {
try std.testing.expectEqual(before.epoch + 1, tb.getContentEpoch());
try std.testing.expect(tb.isViewDirty(view));
try std.testing.expectEqual(@as(u32, 0), tb.link_tracker.?.getLinkCount());
try std.testing.expectEqual(@as(u32, if (input.len == 0) 1 else 2), tb.getHighlightCount());
break;
}
try std.testing.expect(succeeded);
+12 -17
View File
@@ -921,16 +921,19 @@ pub const UnifiedTextBuffer = struct {
};
var spans: @TypeOf(self.line_spans) = .empty;
var highlights: @TypeOf(self.line_highlights) = .empty;
defer {
for (spans.items) |*list| list.deinit(self.global_allocator);
spans.deinit(self.global_allocator);
for (highlights.items) |*list| list.deinit(self.global_allocator);
highlights.deinit(self.global_allocator);
}
var highlights: std.ArrayListUnmanaged(Highlight) = .empty;
defer highlights.deinit(self.global_allocator);
const span_count = if (self.getHighlightCount() == 0) 0 else self.line_highlights.items.len;
try spans.ensureTotalCapacity(self.global_allocator, span_count);
// User highlights stay only on lines that the new text still has.
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..span_count], 0..) |list, line_idx| {
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];
@@ -938,12 +941,12 @@ pub const UnifiedTextBuffer = struct {
if (segment.isBreak()) break;
if (segment.asText()) |chunk| line_width += chunk.width_cols;
}
highlights.clearRetainingCapacity();
highlights.appendAssumeCapacity(.empty);
for (list.items) |hl| {
if (!hl.internal) try highlights.append(self.global_allocator, hl);
if (!hl.internal) try highlights.items[line_idx].append(self.global_allocator, hl);
}
spans.appendAssumeCapacity(.empty);
try self.buildLineSpans(highlights.items, line_width, &spans.items[line_idx]);
try self.buildLineSpans(highlights.items[line_idx].items, line_width, &spans.items[line_idx]);
}
// History nodes are shared: publish undo on the live rope, after all preparation.
@@ -960,16 +963,8 @@ pub const UnifiedTextBuffer = struct {
self._rope.root = candidate.root;
self._rope.version = candidate.version;
}
for (self.line_highlights.items) |*list| {
var kept: usize = 0;
for (list.items) |hl| {
if (hl.internal) continue;
list.items[kept] = hl;
kept += 1;
}
list.items.len = kept;
}
self.internal_highlight_count = 0;
std.mem.swap(@TypeOf(self.line_highlights), &self.line_highlights, &highlights);
std.mem.swap(@TypeOf(self.line_spans), &self.line_spans, &spans);
// Replacement flushes pending spans even inside a highlight transaction.
self.dirty_span_lines.clearRetainingCapacity();