Skip to content

Commit ba1b753

Browse files
authored
perf(tui): optimize editor and text layout wrapping
Optimize TUI editor/plain-text layout hot paths.\n\n- reuse wrap segment widths in editor visual-line layout\n- reuse segment widths for editor logical column advancement\n- preallocate editor and text layout line arrays\n- avoid redundant token whitespace scans\n- add printable-ASCII token width fast path\n\nAutoresearch benchmark improved from 514.174ms to 73.791ms with edit/text line counts unchanged.\n\nChecks passed: zig build test, CI formatting, CI linux/macos tests.
1 parent 566fa48 commit ba1b753

3 files changed

Lines changed: 40 additions & 15 deletions

File tree

src/tui/edit/layout.zig

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ fn buildVirtualLines(buffer: *const EditBuffer, config: LayoutConfig, allocator:
5454
errdefer lines.deinit(allocator);
5555

5656
const items = buffer.text();
57+
try lines.ensureTotalCapacity(allocator, estimateVirtualLineCapacity(items.len, config.width_cols));
5758
const first_text_width = if (config.width_cols > config.first_line_text_col)
5859
config.width_cols - config.first_line_text_col
5960
else
@@ -103,6 +104,11 @@ fn buildVirtualLines(buffer: *const EditBuffer, config: LayoutConfig, allocator:
103104
return try lines.toOwnedSlice(allocator);
104105
}
105106

107+
fn estimateVirtualLineCapacity(text_len: usize, width_cols: u32) usize {
108+
if (text_len == 0) return 1;
109+
return @max(@as(usize, 1), text_len / @max(@as(usize, width_cols), 1) + 2);
110+
}
111+
106112
fn appendWrappedSlices(
107113
out: *std.ArrayList(VirtualLine),
108114
line_text: []const u8,
@@ -129,14 +135,17 @@ fn appendWrappedSlices(
129135
.byte_end = base_start + @as(u32, @intCast(segment.end)),
130136
.logical_line = logical_line,
131137
.logical_col_start = logical_col_start,
132-
.width_cols = @intCast(grapheme_mod.strWidth(line_text[segment.start..segment.end], width_method)),
138+
.width_cols = segment.width_cols,
133139
.text_col = current_text_col,
134140
.kind = current_kind,
135141
});
136142

137143
if (segment.next_start >= line_text.len) break;
138144

139-
logical_col_start += @intCast(grapheme_mod.strWidth(line_text[start..segment.next_start], width_method));
145+
// Editor layout uses default SegmentOptions, which preserve separator bytes:
146+
// the measured segment covers the same byte range used for logical column advancement.
147+
std.debug.assert(segment.next_start == segment.end);
148+
logical_col_start += segment.width_cols;
140149
start = segment.next_start;
141150
current_kind = .wrapped_continuation;
142151
current_text_col = continuation_text_col;

src/tui/text/layout.zig

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ fn wrapLogicalLines(text: []const u8, allocator: std.mem.Allocator, max_width: ?
164164

165165
var lines: std.ArrayListUnmanaged(Line) = .empty;
166166
errdefer lines.deinit(allocator);
167+
try lines.ensureTotalCapacity(allocator, estimateLineCapacity(text.len, max_width));
167168

168169
if (text.len == 0) {
169170
try lines.append(allocator, .{ .start = 0, .end = 0 });
@@ -200,6 +201,7 @@ fn wrapLogicalLinesWithBreaks(text: []const u8, allocator: std.mem.Allocator, ma
200201

201202
var lines: std.ArrayListUnmanaged(Line) = .empty;
202203
errdefer lines.deinit(allocator);
204+
try lines.ensureTotalCapacity(allocator, estimateLineCapacity(text.len, max_width));
203205

204206
if (text.len == 0) {
205207
try lines.append(allocator, .{ .start = 0, .end = 0 });
@@ -223,6 +225,12 @@ fn wrapLogicalLinesWithBreaks(text: []const u8, allocator: std.mem.Allocator, ma
223225
return try lines.toOwnedSlice(allocator);
224226
}
225227

228+
fn estimateLineCapacity(text_len: usize, max_width: ?u32) usize {
229+
if (text_len == 0) return 1;
230+
const width = if (max_width) |w| @max(@as(usize, w), 1) else text_len;
231+
return @max(@as(usize, 1), text_len / width + 2);
232+
}
233+
226234
fn appendCharWrappedLine(lines: *std.ArrayListUnmanaged(Line), allocator: std.mem.Allocator, text: []const u8, line_start: usize, line_end: usize, max_width: u32, width_method: grapheme.WidthMethod) !void {
227235
if (line_start == line_end) {
228236
try lines.append(allocator, .{ .start = line_start, .end = line_start });

src/tui/wrap/breaks.zig

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@ pub fn nextSegment(
4242

4343
while (current_start < line.len) {
4444
const token = nextToken(line, current_start);
45-
const token_text = token.text(line);
46-
const token_width = grapheme.strWidth(token_text, width_method);
47-
const token_is_whitespace = isAllWhitespace(token_text);
45+
const token_width = token.width(line, width_method);
46+
const token_is_whitespace = isWhitespace(line[token.start]);
4847

4948
if (token_width > max_width) {
5049
if (current_width > 0) {
@@ -105,33 +104,35 @@ pub fn nextSegment(
105104
pub const Token = struct {
106105
start: usize,
107106
end: usize,
107+
ascii_printable: bool,
108108

109109
pub fn text(self: Token, line: []const u8) []const u8 {
110110
return line[self.start..self.end];
111111
}
112+
113+
pub fn width(self: Token, line: []const u8, width_method: grapheme.WidthMethod) usize {
114+
if (self.ascii_printable) return self.end - self.start;
115+
return grapheme.strWidth(self.text(line), width_method);
116+
}
112117
};
113118

114119
pub fn nextToken(line: []const u8, start: usize) Token {
115-
if (start >= line.len) return .{ .start = start, .end = start };
120+
if (start >= line.len) return .{ .start = start, .end = start, .ascii_printable = true };
116121
const is_ws = isWhitespace(line[start]);
117122
var pos = start;
123+
var ascii_printable = true;
118124
while (pos < line.len) : (pos += 1) {
119-
if (isWhitespace(line[pos]) != is_ws) break;
125+
const c = line[pos];
126+
if (isWhitespace(c) != is_ws) break;
127+
if (c < 0x20 or c > 0x7E) ascii_printable = false;
120128
}
121-
return .{ .start = start, .end = pos };
129+
return .{ .start = start, .end = pos, .ascii_printable = ascii_printable };
122130
}
123131

124132
pub fn isWhitespace(c: u8) bool {
125133
return c == ' ' or c == '\t';
126134
}
127135

128-
pub fn isAllWhitespace(s: []const u8) bool {
129-
for (s) |c| {
130-
if (!isWhitespace(c)) return false;
131-
}
132-
return true;
133-
}
134-
135136
pub fn skipWhitespace(line: []const u8, start: usize) usize {
136137
var pos = start;
137138
while (pos < line.len and isWhitespace(line[pos])) : (pos += 1) {}
@@ -191,3 +192,10 @@ test "nextSegment preserve policy keeps separator bytes on previous line" {
191192
const seg2 = nextSegment("hello world", seg1.next_start, 5, true, .{}, .wcwidth).?;
192193
try testing.expectEqualStrings("world", seg2.text("hello world"));
193194
}
195+
196+
test "token width fast path matches grapheme width for printable ascii" {
197+
const text = "plain ascii token";
198+
const token = nextToken(text, 0);
199+
try testing.expect(token.ascii_printable);
200+
try testing.expectEqual(grapheme.strWidth(token.text(text), .wcwidth), token.width(text, .wcwidth));
201+
}

0 commit comments

Comments
 (0)