summaryrefslogtreecommitdiff
path: root/src/file_pane.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /src/file_pane.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/file_pane.zig')
-rw-r--r--src/file_pane.zig1059
1 files changed, 0 insertions, 1059 deletions
diff --git a/src/file_pane.zig b/src/file_pane.zig
deleted file mode 100644
index 9b828030..00000000
--- a/src/file_pane.zig
+++ /dev/null
@@ -1,1059 +0,0 @@
-//! File panes: everything a Pane does BECAUSE it has `file: ?State` set — the
-//! disk read, the content swap undo/redo commits through, the tree-sitter
-//! highlight window, and the two render passes only a file has (the line
-//! number gutter and the syntax recolor). The rest of a file pane's behaviour
-//! is the pane machinery in pardes.zig, which does not care what kind it is.
-const std = @import("std");
-const vaxis = @import("vaxis");
-const pardes = @import("pardes.zig");
-const config = @import("config.zig");
-const Pardes = pardes.Pardes;
-const Pane = pardes.Pane;
-const modal = @import("modal.zig");
-const look = @import("look.zig");
-const output_pane = @import("output_pane.zig");
-const syntax = @import("syntax.zig");
-const tracy = @import("tracy.zig");
-const term_pane = @import("term_pane.zig");
-const dump = @import("dump.zig");
-const limits = @import("limits.zig");
-
-const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
-
-/// Content and primary selection at one file edit boundary. Keeping only the
-/// primary avoids putting pardes.MAX_SELS ranges in every history entry.
-pub const Snapshot = struct {
- content: []u8,
- cur_row: i32,
- cur_col: i32,
- vsel: pardes.CharSel,
-};
-
-/// A file pane's backing: owned content, its derived caches, and undo history.
-pub const State = struct {
- path: []u8,
- content: []u8,
- /// Monotonic content identity for asynchronous edits. Every content swap
- /// goes through setContent, which bumps this; a pipe completion accepted
- /// against another revision would overwrite intervening work.
- revision: u32 = 0,
- /// Revision last known to match disk, after Save or an external reload.
- /// Equal means the screen matches disk.
- saved_revision: u32 = 0,
- /// Non-null for a generated output buffer rather than an on-disk file.
- output: ?output_pane.Output = null,
- scroll: usize = 0,
- /// `line_starts[i]` is line i's byte offset. Empty means not built yet.
- line_starts: []usize = &.{},
- /// One tree_sitter_gpa-owned syntax.Syn byte per highlighted source byte.
- highlights: []u8 = &.{},
- highlight_start: usize = 0,
- syntax_dirty: bool = true,
- undo: [limits.undo_max]Snapshot = undefined,
- undo_len: usize = 0,
- redo: [limits.undo_max]Snapshot = undefined,
- redo_len: usize = 0,
-};
-
-/// Serialize file-owned bytes and identity; output origin vocabulary is
-/// supplied by output_pane at the core dispatch edge.
-pub fn dumpPane(
- arena: std.mem.Allocator,
- pane: *const Pane,
- file: *const State,
- tag: []const u8,
- body: []const u8,
- scroll: usize,
- origin: []const u8,
- origin_arg: []const u8,
-) !dump.Pane {
- return .{
- .kind = .file,
- .tag = tag,
- .body = body,
- .scroll = scroll,
- .cols = pane.cols,
- .rows = pane.rows,
- .vweight = pane.vweight,
- .file = .{
- .path = file.path,
- .content = file.content,
- .content_b64 = try dump.encodeBytes(arena, file.content),
- .origin = origin,
- .origin_arg = origin_arg,
- },
- };
-}
-
-pub fn graphemeDisplayWidth(grapheme: []const u8) usize {
- if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
- // A one-byte printable ASCII grapheme is one cell, and saying so here rather than asking
- // `gwidth` costs a comparison instead of a Unicode table walk. `gwidth` was 6.9% of a profiled
- // keystroke at the P4's geometry, essentially all of it answering this question about `y`.
- // Bounded to 0x20..0x7e on purpose: DEL and the C0 controls are not one printable cell, and
- // `gwidth` is still the authority on them.
- if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1;
- return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
-}
-
-pub fn byteDisplayWidth(byte: u8) usize {
- return if (byte == '\t') config.tab_width else 1;
-}
-
-pub fn displayWidth(text: []const u8) usize {
- var width: usize = 0;
- var at: usize = 0;
- while (at < text.len) {
- const end = modal.nextGrapheme(text, at);
- width +|= graphemeDisplayWidth(text[at..end]);
- at = end;
- }
- return width;
-}
-
-/// Source byte at a zero-based display column. Every cell occupied by a tab
-/// maps back to that one tab byte.
-pub fn byteAtDisplay(text: []const u8, display_col: usize) usize {
- var col: usize = 0;
- var at: usize = 0;
- while (at < text.len) {
- const end = modal.nextGrapheme(text, at);
- const next = col +| graphemeDisplayWidth(text[at..end]);
- if (display_col < next) return at;
- col = next;
- at = end;
- }
- return text.len;
-}
-
-/// File cursor columns may live past EOL. Tabs expand before that boundary;
-/// every virtual column after it remains one screen cell.
-pub fn rawDisplayCol(line_text: []const u8, raw_col: usize) usize {
- const bounded = modal.graphemeStart(line_text, @min(raw_col, line_text.len));
- return displayWidth(line_text[0..bounded]) +| (raw_col -| line_text.len);
-}
-
-pub fn rawAtDisplay(line_text: []const u8, display_col: usize) usize {
- const width = displayWidth(line_text);
- if (display_col > width) return line_text.len +| (display_col - width);
- return byteAtDisplay(line_text, display_col);
-}
-
-pub fn byteAtDisplayFrom(line_text: []const u8, from_raw: usize, display_col: usize) usize {
- if (from_raw >= line_text.len) return from_raw +| display_col;
- const from = modal.graphemeStart(line_text, from_raw);
- return from +| rawAtDisplay(line_text[from..], display_col);
-}
-
-pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 {
- const from_display = rawDisplayCol(line_text, from_raw);
- const to_display = rawDisplayCol(line_text, to_raw);
- if (to_display >= from_display) return @intCast(to_display - from_display);
- return -@as(i32, @intCast(from_display - to_display));
-}
-
-pub fn lineDisplayEndOffset(line_text: []const u8, from_raw: usize, at_raw: usize) i32 {
- const start = lineDisplayOffset(line_text, from_raw, at_raw);
- if (at_raw >= line_text.len) return start;
- const at = modal.graphemeStart(line_text, at_raw);
- const end = modal.nextGrapheme(line_text, at);
- return start + @as(i32, @intCast(graphemeDisplayWidth(line_text[at..end]))) - 1;
-}
-
-pub fn sourceLine(pane: *const Pane, row: i32) []const u8 {
- const f = pane.file orelse return "";
- if (row < 0) return "";
- return modal.lineSlice(f.content, @intCast(row));
-}
-
-pub fn displayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 {
- const line_text = sourceLine(pane, row);
- const from: usize = @intCast(@max(0, from_raw));
- const to: usize = @intCast(@max(0, to_raw));
- return lineDisplayOffset(line_text, from, to);
-}
-
-pub fn displayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 {
- const line_text = sourceLine(pane, row);
- return lineDisplayEndOffset(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, at_raw)));
-}
-
-pub fn byteAtRowDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 {
- const line_text = sourceLine(pane, row);
- return @intCast(byteAtDisplayFrom(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, display_col))));
-}
-
-/// Convert between rendered cells and UTF-8 byte columns. Tag rows always
-/// need grapheme conversion; file body rows additionally skip PREFIX_W.
-pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize {
- if (row < pardes.BOX_H) return rawAtDisplay(line_text, display_col);
- if (pane.file == null) return rawAtDisplay(line_text, display_col);
- const prefix = @min(@as(usize, config.PREFIX_W), line_text.len);
- if (display_col <= prefix) return display_col;
- return prefix +| rawAtDisplay(line_text[prefix..], display_col - prefix);
-}
-
-pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize {
- if (row < pardes.BOX_H) return rawDisplayCol(line_text, byte_col);
- if (pane.file == null) return rawDisplayCol(line_text, byte_col);
- const prefix = @min(@as(usize, config.PREFIX_W), line_text.len);
- if (byte_col <= prefix) return byte_col;
- return prefix +| rawDisplayCol(line_text[prefix..], byte_col - prefix);
-}
-
-test "display columns map complete Unicode graphemes" {
- const text = "é界e\u{301}x";
- try std.testing.expectEqual(@as(usize, 5), displayWidth(text));
- try std.testing.expectEqual(@as(usize, 0), byteAtDisplay(text, 0));
- try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 1));
- try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 2));
- try std.testing.expectEqual(@as(usize, 5), byteAtDisplay(text, 3));
- try std.testing.expectEqual(@as(usize, 8), byteAtDisplay(text, 4));
- try std.testing.expectEqual(text.len, byteAtDisplay(text, 5));
- try std.testing.expectEqual(@as(usize, 3), rawDisplayCol(text, 5));
- try std.testing.expectEqual(@as(usize, 5), rawAtDisplay(text, 3));
- try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("👩\u{200d}🚀"));
-}
-
-test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" {
- // The arm claims a one-byte printable ASCII grapheme is one cell without asking `gwidth`. That
- // is only worth having if the two never disagree, so ask both for every byte the arm can see -
- // including \t, \r, the rest of the C0 controls and DEL, which the range test excludes and
- // which must therefore still come back from `gwidth` (or, for the tab, from the config).
- const ref = struct {
- fn width(grapheme: []const u8) usize {
- if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
- return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
- }
- }.width;
-
- var one: [1]u8 = undefined;
- var b: u8 = 0;
- while (b < 0x80) : (b += 1) {
- one[0] = b;
- try std.testing.expectEqual(ref(one[0..1]), graphemeDisplayWidth(one[0..1]));
- }
- // Multi-byte clusters never reach the arm (len != 1), so they pin that it does not widen its
- // claim: a combining sequence and a ZWJ emoji are one and two cells, a CJK glyph is two, and
- // an invalid byte is the one cell `gwidth` reports for U+FFFD-shaped input.
- for ([_][]const u8{
- "e\u{301}", "a\u{903}", "1\u{fe0f}\u{20e3}", "\u{4e16}",
- "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8",
- }) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g));
-}
-
-test "the ASCII run in fitEnd survives an exhaustive byte sweep" {
- // The case list in the test above is hand-picked; this one is not. Every byte 0x00..0x7f is
- // placed next to every neighbour that can change the answer - a combining mark, a ZWJ
- // sequence, a spacing mark, a variation selector, a wide glyph, and a bad start byte, a
- // truncated tail and a bad continuation - and every break column is compared against the
- // grapheme walk. An off-by-one column here moves text between wrapped rows, so equality is
- // exact, not approximate.
- const reference = struct {
- fn fitEnd(text: []const u8, start: usize, width: usize) usize {
- var end = start;
- var used: usize = 0;
- while (end < text.len) {
- const next_end = modal.nextGrapheme(text, end);
- const next_used = used +| graphemeDisplayWidth(text[end..next_end]);
- if (next_used > width) return if (end == start) next_end else end;
- used = next_used;
- end = next_end;
- }
- return end;
- }
- }.fitEnd;
-
- const neighbours = [_][]const u8{
- "", "z", "\u{301}", "\u{200d}\u{1f680}",
- "\u{903}", "\u{fe0f}", "\u{4e16}", "\u{1f642}",
- "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", "\xe4\x28\xb8",
- };
- var buf: [16]u8 = undefined;
- // The same pair again behind an ASCII prefix, so a break can land exactly at the run boundary
- // as well as before it and inside the multi-byte cluster that follows it.
- var prefixed: [18]u8 = undefined;
- prefixed[0] = 'a';
- prefixed[1] = 'b';
- var b: u8 = 0;
- while (b < 0x80) : (b += 1) {
- buf[0] = b;
- for (neighbours) |tail| {
- @memcpy(buf[1..][0..tail.len], tail);
- const pair = buf[0 .. 1 + tail.len];
- @memcpy(prefixed[2..][0..pair.len], pair);
- for ([_][]const u8{ pair, prefixed[0 .. 2 + pair.len] }) |text| {
- var width: usize = 0;
- while (width <= text.len + 3) : (width += 1) {
- var start: usize = 0;
- while (start <= text.len) : (start += 1) {
- std.testing.expectEqual(
- reference(text, start, width),
- fitEnd(text, start, width),
- ) catch |e| {
- std.debug.print("fitEnd({any}, {d}, {d})\n", .{ text, start, width });
- return e;
- };
- }
- }
- }
- }
- }
-}
-
-fn fitEnd(text: []const u8, start: usize, width: usize) usize {
- var end = start;
- var used: usize = 0;
- // ASCII RUN. This is the loop a wrapped line pays per character, and it asks two function calls
- // to learn what arithmetic knows: `modal.nextGrapheme` and `graphemeDisplayWidth` each answer
- // ASCII in constant time, but they answer once per character and a 640-column line asks 640
- // times. A printable ASCII byte whose successor is also ASCII is a complete grapheme cluster one
- // column wide - the same guard, and the same reason, as `Surface.print` and `modal.nextGrapheme`
- // - so consume the run here and leave anything else to the general path below.
- while (used < width and end < text.len) {
- const b = text[end];
- if (b < 0x20 or b >= 0x7f) break;
- if (end + 1 < text.len and text[end + 1] >= 0x80) break;
- used += 1;
- end += 1;
- }
- while (end < text.len) {
- const next_end = modal.nextGrapheme(text, end);
- const next_used = used +| graphemeDisplayWidth(text[end..next_end]);
- if (next_used > width) return if (end == start) next_end else end;
- used = next_used;
- end = next_end;
- }
- return end;
-}
-
-test "the ASCII run in fitEnd cuts where the grapheme walk would" {
- // fitEnd decides where a wrapped row BREAKS, so a fast path that is off by one column moves
- // text on screen. This pins it to the general walk it replaces rather than to a transcribed
- // expectation: same inputs, both routes, every width from 0 past the end of the string.
- const reference = struct {
- fn fitEnd(text: []const u8, start: usize, width: usize) usize {
- var end = start;
- var used: usize = 0;
- while (end < text.len) {
- const next_end = modal.nextGrapheme(text, end);
- const next_used = used +| graphemeDisplayWidth(text[end..next_end]);
- if (next_used > width) return if (end == start) next_end else end;
- used = next_used;
- end = next_end;
- }
- return end;
- }
- }.fitEnd;
-
- const cases = [_][]const u8{
- "",
- "hello world",
- // the fast path must hand over at the first non-ASCII byte, mid-run
- "abc\u{00e9}def",
- // a wide glyph is two columns, so a width boundary can land inside it
- "ab\u{4e16}\u{754c}cd",
- // a cluster the fast path must not split
- "a\u{0301}bc",
- // tabs and controls are excluded from the fast path by the range test
- "ab\tcd",
- "ab\rcd",
- // an ASCII byte followed by a continuation byte is NOT its own cluster
- "e\u{0301}x",
- "\u{1f1e6}\u{1f1e7}ok",
- };
- for (cases) |text| {
- var width: usize = 0;
- while (width <= text.len + 3) : (width += 1) {
- var start: usize = 0;
- while (start <= text.len) : (start += 1) {
- try std.testing.expectEqual(
- reference(text, start, width),
- fitEnd(text, start, width),
- );
- }
- }
- }
-}
-
-pub fn lineCount(content: []const u8) usize {
- return std.mem.count(u8, content, "\n") + 1;
-}
-
-/// THE LINE INDEX, built on demand: `line_starts[i]` is the byte offset where
-/// line i begins and its length is the line count. Without it, every question
-/// about lines is a scan from byte 0, and a file pane asks several of them per
-/// keystroke — the scrollbar's total, the scroll clamp, the syntax window's
-/// bounds, the body's first visible line. On a 300k-line file that was ~35% of
-/// the whole frame, and it is what made a single `j` cost 25ms.
-///
-/// INVALIDATION — the part that rots if nobody says it out loud. The index is
-/// dropped in EXACTLY ONE PLACE: setContent, immediately below, which is the
-/// funnel every content swap in the editor already goes through (typing, undo,
-/// redo, a save's normalisation, an output buffer refilling itself). A State
-/// built by a struct literal starts with an empty index, and empty reads as
-/// "not built yet" — a real index always has at least one entry, because a
-/// file always has at least one line. So there is one and only one way to make
-/// this wrong: assign `f.content` without going through setContent. Don't.
-///
-/// Fails only when the index could not be allocated. nlines and lineStart
-/// swallow that and scan the old way, so OOM there is slow rather than wrong;
-/// callers that need the whole table say `try` and drop the keystroke, which
-/// is what they already did when their own arena ran out.
-pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize {
- if (f.line_starts.len > 0) return f.line_starts;
- // Exact allocation: deinitPane frees `line_starts` itself, so the stored
- // slice must span the complete allocation rather than spare capacity.
- const starts = try gpa.alloc(usize, lineCount(f.content));
- starts[0] = 0;
- var i: usize = 1;
- var off: usize = 0;
- while (std.mem.indexOfScalarPos(u8, f.content, off, '\n')) |nl| {
- off = nl + 1;
- starts[i] = off;
- i += 1;
- }
- f.line_starts = starts;
- return starts;
-}
-
-/// line count, O(1) once the index is warm
-pub fn nlines(gpa: std.mem.Allocator, f: *State) usize {
- const idx = lineIndex(gpa, f) catch return lineCount(f.content);
- return idx.len;
-}
-
-/// byte offset of line `row`, or content.len past the end — modal
-/// .lineStartOffset's contract exactly, without its walk
-pub fn lineStart(gpa: std.mem.Allocator, f: *State, row: usize) usize {
- const idx = lineIndex(gpa, f) catch return modal.lineStartOffset(f.content, row);
- return if (row >= idx.len) f.content.len else idx[row];
-}
-
-pub fn cursorLines(arena: std.mem.Allocator, pane: *Pane, f: *State) ![]const []const u8 {
- const index = try lineIndex(pane.gpa, f);
- const lines = try arena.alloc([]const u8, index.len);
- for (index, 0..) |start, i| {
- const end = if (i + 1 < index.len) index[i + 1] - 1 else f.content.len;
- lines[i] = f.content[start..end];
- }
- return lines;
-}
-
-/// Use the file's line index only when `text` is its complete live content.
-/// Edit-buffer fragments and other temporary text retain modal's scan path.
-fn contentIndex(pane: *Pane, text: []const u8) ?[]const usize {
- const f = if (pane.file) |*file| file else return null;
- if (text.ptr != f.content.ptr or text.len != f.content.len) return null;
- return lineIndex(pane.gpa, f) catch null;
-}
-
-pub fn textOffset(pane: *Pane, text: []const u8, cursor: modal.Cursor) usize {
- const index = contentIndex(pane, text) orelse return modal.hxOff(text, cursor);
- const row = @min(cursor.row, index.len - 1);
- const start = index[row];
- const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len;
- return start + modal.graphemeStart(text[start..end], @min(cursor.col, end - start));
-}
-
-pub fn textLineStart(pane: *Pane, text: []const u8, row: usize) usize {
- const index = contentIndex(pane, text) orelse return modal.lineStartOffset(text, row);
- return if (row >= index.len) text.len else index[row];
-}
-
-pub fn textLineCount(pane: *Pane, text: []const u8) usize {
- const index = contentIndex(pane, text) orelse return modal.hxLineCount(text);
- return index.len;
-}
-
-pub fn textPosition(pane: *Pane, text: []const u8, offset: usize) modal.Cursor {
- const index = contentIndex(pane, text) orelse return modal.hxPos(text, offset);
- const bounded = @min(offset, text.len);
- const row = std.sort.upperBound(usize, index, bounded, struct {
- fn cmp(key: usize, item: usize) std.math.Order {
- return std.math.order(key, item);
- }
- }.cmp) - 1;
- const start = index[row];
- const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len;
- return .{ .row = row, .col = modal.graphemeStart(text[start..end], @min(bounded - start, end - start)) };
-}
-
-pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
- const content = try look.readFile(p.gpa, path);
- errdefer p.gpa.free(content);
- const path_copy = try p.gpa.dupe(u8, path);
- errdefer p.gpa.free(path_copy);
- const pane = try p.newDocPane(id);
- const total = lineCount(content);
- const scroll: usize = if (line > 0 and line <= total) line - 1 else 0;
- pane.file = .{ .path = path_copy, .content = content, .scroll = scroll };
- pane.cur_pinned = true;
- pane.cur_row = @intCast(scroll);
- // watches follow pane lifetime: this is the only place a real file is read
- // off disk, and deinitPane is the only place one goes away
- p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
- return pane;
-}
-
-/// Rebuild a dumped file or output buffer. Byte ownership, output identity,
-/// cursor projection, and file watching are all properties of this payload;
-/// column registration and custom tag restoration remain core invariants.
-pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane {
- const saved = src.file.?;
- const content: []u8 = if (saved.content_b64.len > 0)
- try dump.decodeBytes(p.gpa, saved.content_b64)
- else
- try p.gpa.dupe(u8, saved.content);
- errdefer p.gpa.free(content);
- const path = try p.gpa.dupe(u8, saved.path);
- errdefer p.gpa.free(path);
-
- const output: ?output_pane.Output = if (output_pane.fromWord(saved.origin)) |origin| blk: {
- var value: output_pane.Output = .{ .from = origin };
- try output_pane.setArg(&value, saved.origin_arg);
- break :blk value;
- } else null;
-
- const pane = try p.newDocPane(id);
- pane.file = .{ .path = path, .content = content, .output = output, .scroll = src.scroll };
- pane.cur_pinned = true;
- pane.cur_row = @intCast(src.scroll);
- pane.cols = @max(1, src.cols);
- pane.rows = @max(1, src.rows);
- // A restored file is watched exactly like one opened from disk. Its dump
- // bytes may differ from disk; the first external write reconciles them and
- // leaves the restored version one undo away. Output buffers have no file.
- if (output == null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
- return pane;
-}
-
-/// Release the complete file payload while the owning pane is still installed
-/// (the slot is needed to identify a disappearing file watch). Common pane
-/// overlays and the shared terminal stub remain the core's responsibility.
-pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
- if (file.output == null) for (p.panes, 0..) |slot, id| {
- if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
- };
- p.gpa.free(file.path);
- p.gpa.free(file.content);
- if (file.line_starts.len > 0) p.gpa.free(file.line_starts);
- if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
- for (file.undo[0..file.undo_len]) |snap| p.gpa.free(snap.content);
- for (file.redo[0..file.redo_len]) |snap| p.gpa.free(snap.content);
-}
-
-/// TELL A SCRIPT WHAT CHANGED, when one is listening.
-///
-/// acme reports edits from the two places that make them — `textinsert` and
-/// `textdelete`, which already know their range — so a replacement arrives as
-/// a `D` record and then an `I`. pardes has no such pair: every edit lands
-/// here as a whole new buffer, so the range is recovered by DIFFING, and
-/// `acmefs.noteReplace` owns both the diff and the D-then-I order.
-///
-/// The cost is two vectorised scans of the content, and it is paid only while
-/// a script holds an `event` file open (`p.fs.listeners`); the editor nobody
-/// is scripting does one branch. The pane lookup is a walk of at most
-/// MAX_PANES slots comparing the FILE pointer — a file pane's state is stored
-/// inline in its pane, so that identifies the pane exactly.
-fn reportEdit(p: *Pardes, f: *State, new: []const u8) void {
- if (p.fs.listeners == 0) return;
- const id = for (p.panes, 0..) |slot, i| {
- const pane = slot orelse continue;
- if (pane.file) |*state| if (state == f) break i;
- } else return;
- pardes.acmefs.noteReplace(p, id, false, f.content, new);
-}
-
-/// The ONE content swap. Everything that edits a file pane lands here, which
-/// is what lets the line index above have a single invalidation point — and
-/// is why one diff HERE is every body edit a script can be told about.
-pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
- reportEdit(p, f, new);
- p.gpa.free(f.content);
- f.content = new;
- f.revision +%= 1;
- if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
- f.line_starts = &.{};
- // the highlights go too, and not just because they are stale: their byte
- // range is what refreshHighlights tests a scroll against, and a range
- // measured on the OLD content would let it skip a re-parse it needs
- if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
- f.highlights = &.{};
- f.highlight_start = 0;
- f.syntax_dirty = true;
-}
-
-/// undo/redo restores the selection recorded with the snapshot (helix keeps
-/// selections in its history transactions), clamped: the content it was taken
-/// against may be shorter than the one it is being restored onto.
-pub fn restoreSnap(pane: *Pane, f: *State, snap: Snapshot) void {
- const n = nlines(pane.gpa, f);
- const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), n - 1);
- const llen = modal.lineSlice(f.content, row).len;
- pane.cur_row = @intCast(row);
- pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen));
- pane.vsel = snap.vsel;
- pane.msel.active = false;
- pane.cur_pinned = true;
- pane.sticky_col = -1;
- pane.ensureCursorVisible();
-}
-
-fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, snap: Snapshot) void {
- if (len.* == slots.len) {
- gpa.free(slots[0].content);
- std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]);
- len.* -= 1;
- }
- slots[len.*] = snap;
- len.* += 1;
-}
-
-pub fn pushUndo(p: *Pardes, pane: *Pane) void {
- const f = if (pane.file) |*file| file else return;
- if (f.undo_len > 0 and std.mem.eql(u8, f.undo[f.undo_len - 1].content, f.content)) return;
- const snap: Snapshot = .{
- .content = p.gpa.dupe(u8, f.content) catch return,
- .cur_row = pane.cur_row,
- .cur_col = pane.cur_col,
- .vsel = pane.vsel,
- };
- pushHistory(p.gpa, &f.undo, &f.undo_len, snap);
- for (f.redo[0..f.redo_len]) |item| p.gpa.free(item.content);
- f.redo_len = 0;
-}
-
-pub fn undo(p: *Pardes, pane: *Pane) void {
- const f = if (pane.file) |*file| file else return;
- if (f.undo_len == 0) return;
- const current: Snapshot = .{
- .content = p.gpa.dupe(u8, f.content) catch return,
- .cur_row = pane.cur_row,
- .cur_col = pane.cur_col,
- .vsel = pane.vsel,
- };
- pushHistory(p.gpa, &f.redo, &f.redo_len, current);
- f.undo_len -= 1;
- const previous = f.undo[f.undo_len];
- setContent(p, f, previous.content);
- restoreSnap(pane, f, previous);
-}
-
-pub fn redo(p: *Pardes, pane: *Pane) void {
- const f = if (pane.file) |*file| file else return;
- if (f.redo_len == 0) return;
- const current: Snapshot = .{
- .content = p.gpa.dupe(u8, f.content) catch return,
- .cur_row = pane.cur_row,
- .cur_col = pane.cur_col,
- .vsel = pane.vsel,
- };
- pushHistory(p.gpa, &f.undo, &f.undo_len, current);
- f.redo_len -= 1;
- const next = f.redo[f.redo_len];
- setContent(p, f, next.content);
- restoreSnap(pane, f, next);
-}
-
-/// Commit an externally rewritten file onto the same undo history as typed
-/// edits. Unsaved work remains one `u` away; there is no third merge state.
-pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void {
- const pane = p.panes[id] orelse return;
- const f = if (pane.file) |*file| file else return;
- if (std.mem.eql(u8, f.content, bytes)) return;
- const new = p.gpa.dupe(u8, bytes) catch return;
- pushUndo(p, pane);
- setContent(p, f, new);
- // These bytes came from the watched path, so the new on-screen revision
- // is already saved. Undoing back to displaced local work bumps revision
- // again and makes that restored edit dirty, as it should.
- f.saved_revision = f.revision;
- // restoreSnap only consumes cursor/selection from this synthetic snapshot.
- restoreSnap(pane, f, .{
- .content = undefined,
- .cur_row = pane.cur_row,
- .cur_col = pane.cur_col,
- .vsel = pane.vsel,
- });
-}
-
-/// re-highlight the visible window of any file whose syntax went stale
-/// (edit, scroll, load) — visible-range-first so big files stay snappy
-pub fn refreshHighlights(p: *Pardes) void {
- const tz = tracy.zone(@src(), "refreshHighlights");
- defer tz.end();
- for (p.panes) |slot| {
- const pane = slot orelse continue;
- if (pane.file == null) continue;
- const f = &pane.file.?;
- if (!f.syntax_dirty) continue;
- if (!p.settings.colors) {
- if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
- f.highlights = &.{};
- f.highlight_start = 0;
- f.syntax_dirty = false;
- continue;
- }
- // What the screen needs coloured right now. If the last parse still
- // covers it, this scroll is free — and that is the whole point of the
- // slack below. Highlights only ever survive while the CONTENT does:
- // setContent throws them away, so these byte offsets cannot be stale.
- const need_start = lineStart(p.gpa, f, f.scroll);
- const need_end = @max(need_start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS));
- if (f.highlights.len > 0 and need_start >= f.highlight_start and
- need_end <= f.highlight_start + f.highlights.len)
- {
- f.syntax_dirty = false;
- continue;
- }
- // How much MORE than the screen to parse. An edit or a fresh open has
- // no previous parse to widen (setContent throws the highlights away),
- // and slack would be pure loss there: every keystroke of typing pays
- // this parse and none of it is amortised over anything. A SCROLL that
- // outran the covered range is the opposite case — take a screenful
- // above and below and the next ~pane.rows rows cost nothing at all.
- // Scrolling used to re-parse the visible window on every single row,
- // which on a file with 8000-column lines is a third of a megabyte per
- // keypress. Three screens once beats one screen forty times.
- //
- // The slack also means those lines are parsed with real context above
- // them, so a construct that opens off-screen now colours correctly —
- // a fidelity gain, and one that cannot reach a file shown from the top
- // (scroll 0 clamps the window to exactly what it always was).
- const slack: usize = if (f.highlights.len == 0) 0 else pane.rows;
- // A RESULTS BUFFER IS COLOURED WHOLE, ONCE. Its rows are independent —
- // each is parsed in isolation against its own grammar — so a window
- // pass buys no fidelity, only amortisation, and pays for it with a
- // burst on every scroll that outran the covered range: a fresh parser,
- // a fresh query cursor and a tree per row, plus the first compile of
- // any grammar the new rows introduce, all inside `render`. Colouring
- // the whole buffer when it is FILLED makes the covered-range check
- // above true forever after, so scrolling one costs nothing at all.
- // Bounded by what fills them: `look.find_max_hits` caps a grep at 512
- // rows, and a rendering is never typed into.
- const whole = pane.colorAlgo() == .locations;
- const start = if (whole) 0 else lineStart(p.gpa, f, f.scroll -| slack);
- const end = if (whole)
- f.content.len
- else
- @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack));
- const new_highlights = (switch (pane.colorAlgo()) {
- .diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end),
- .locations => syntax.highlightLocations(p.tree_sitter_gpa, f.content, start, end),
- else => syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end),
- }) catch {
- f.syntax_dirty = false;
- continue;
- };
- if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
- f.highlights = new_highlights;
- f.highlight_start = if (f.highlights.len > 0) start else 0;
- f.syntax_dirty = false;
- }
-}
-
-/// The width a wrapped row of THIS pane holds, in cells, or 0 when the pane is
-/// not wrapping — the render decision, named once so motion cannot disagree
-/// with paint. One column is left for the break marker: a row that filled its
-/// last cell would have nowhere to say it continues. A pane taller than the
-/// map refuses to wrap rather than record part of itself (see Pane.wrap_line).
-pub fn wrapWidth(pane: *const Pane, wrap: bool) usize {
- if (!wrap or pane.rows > pane.wrap_line.len) return 0;
- return @max(1, @as(usize, pane.cols -| config.PREFIX_W) -| 1);
-}
-
-pub const VisualRow = struct { start: usize, end: usize };
-
-/// The visual row of `line` holding byte `col`: `[start, end)`, where `end` is
-/// where the next visual row of the same line begins and equals `line.len` on
-/// the last one. This is the same walk `fillBody` renders with, so `gj`/`gk`
-/// step exactly the breaks a reader sees. `width == 0` (not wrapping) makes
-/// the whole line one visual row, which is what collapses visual motion onto
-/// textual motion instead of special-casing it upstream.
-pub fn visualRow(line: []const u8, col: usize, width: usize) VisualRow {
- if (width == 0) return .{ .start = 0, .end = line.len };
- var start: usize = 0;
- while (true) {
- const end = fitEnd(line, start, width);
- if (col < end or end >= line.len) return .{ .start = start, .end = end };
- start = end;
- }
-}
-
-test "visual rows partition a line at the breaks the body renders" {
- const line = "abcdefgh";
- try std.testing.expectEqual(VisualRow{ .start = 0, .end = line.len }, visualRow(line, 5, 0));
- try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 0, 3));
- try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 2, 3));
- try std.testing.expectEqual(VisualRow{ .start = 3, .end = 6 }, visualRow(line, 3, 3));
- // Past the end (a cursor on the newline) names the LAST row, and a short
- // line is one row however narrow the pane is.
- try std.testing.expectEqual(VisualRow{ .start = 6, .end = 8 }, visualRow(line, line.len, 3));
- try std.testing.expectEqual(VisualRow{ .start = 0, .end = 0 }, visualRow("", 0, 3));
- // A grapheme wider than the row still occupies exactly one row.
- try std.testing.expectEqual(VisualRow{ .start = 0, .end = 4 }, visualRow("👩x", 0, 1));
-}
-
-/// the body a file pane renders: `pane.rows` SCREEN rows from the scroll
-/// offset, each behind its right-aligned line number, then cut by hscroll.
-///
-/// With `wrap` on a line too long for the pane takes several rows instead of
-/// running off the right edge, and this is where that happens — it is a render
-/// property, and the one thing the rest of the editor reads back is the map:
-/// which line each row showed and at which byte column it began, recorded into
-/// pane.wrap_line/wrap_col as the rows are built. `wrap_n` stays 0 for an
-/// unwrapped body, and that is the value the readers treat as "rows are
-/// lines", so the off path never consults an array.
-pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *State, wrap: bool) ![]const u8 {
- const width = wrapWidth(pane, wrap);
- pane.wrap_n = 0;
-
- // Count the exact rendered bytes first. Unwrapped source lines are not
- // bounded by the pane width, so a rows*cols buffer would either truncate
- // them or quietly restore a growable builder under another name.
- const len = fillBody(null, pane, f, width, false);
- const out = try arena.alloc(u8, len);
- const filled = fillBody(out, pane, f, width, true);
- std.debug.assert(filled == out.len);
- return out;
-}
-
-/// Run the file-body row walk. With no destination it is the exact sizing
-/// pass; with one it fills that allocation and records the wrapping map.
-fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) usize {
- if (record_wrap) pane.wrap_n = 0;
- // start ON the first visible line instead of walking the file to it: this
- // walk was O(f.scroll) and recolorSyntax below ran the identical one again
- var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n');
- // scrolled past EOF (an edit shortened the file under a stale scroll): the
- // old walk left the iterator dry, so drop the one empty line a slice split
- // still yields, or the body grows a phantom numbered row
- if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next();
- // the line the NEXT row comes from and the byte column of it that row
- // starts at — the two the map records, walked forward by the loop
- var abs: i32 = @intCast(f.scroll);
- var at: usize = 0;
- var cur = flines.next();
- var written: usize = 0;
- for (0..pane.rows) |i| {
- if (i > 0) {
- if (dst) |out| out[written] = '\n';
- written += 1;
- }
- if (width > 0 and record_wrap) {
- pane.wrap_line[i] = abs;
- pane.wrap_col[i] = @intCast(at);
- pane.wrap_n = @intCast(i + 1);
- }
- if (cur) |text| {
- var lbuf: [16]u8 = undefined;
- // unsigned: {d} prints a leading '+' for signed ints
- const lineno: usize = @intCast(abs + 1);
- // the number belongs to the LINE, so only its first row carries
- // one — repeated down a wrapped line it would read as several
- // lines, which is exactly what this is not
- const prefix = if (at > 0)
- " "
- else
- std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " ";
- if (dst) |out| @memcpy(out[written..][0..prefix.len], prefix);
- written += prefix.len;
-
- // Wrap and horizontal-scroll cuts are always grapheme boundaries.
- // Source columns remain byte offsets, while widths are terminal
- // cells; keeping the conversion here prevents a view operation
- // from manufacturing malformed UTF-8.
- const end = if (width == 0) text.len else fitEnd(text, at, width);
- const take = end - at;
- const cut = if (pane.hscroll > 0 and width == 0)
- modal.graphemeStart(text[at..end], @min(@as(usize, @intCast(pane.hscroll)), take))
- else
- 0;
- const shown = text[at + cut .. end];
- if (dst) |out| @memcpy(out[written..][0..shown.len], shown);
- written += shown.len;
- if (width > 0 and end < text.len) {
- at = end;
- } else {
- abs += 1;
- at = 0;
- cur = flines.next();
- }
- } else abs += 1;
- }
- return written;
-}
-
-/// line-number gutter: mute the first PREFIX_W columns. Cheap chrome, not
-/// gated on settings.colors; selection/cursor passes still win. The cursor row's
-/// number takes the tag style (same row math as renderPane's cursor pass) so
-/// the eye finds the current line.
-pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void {
- const s = &p.surface;
- const ch = p.chromeTheme();
- const goff = pane.scroll();
- const gcur = term_pane.gridCursor(pane);
- const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff;
- // the cursor's LINE, not its row: wrapped, one line owns a run of rows and
- // the number sits on the first of them, so the whole run lights up — the
- // gutter is naming the line you are on, and that is still one line
- const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32);
- // the body's first row, the way renderPane derives it (Tagbottom)
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
- var vr: u16 = 0;
- while (vr < body_h) : (vr += 1) {
- const row_line: i32 = if (pane.wrap_n == 0)
- goff + @as(i32, vr)
- else if (vr < pane.wrap_n) pane.wrap_line[vr] else std.math.maxInt(i32);
- const on_cursor = row_line == cur_line;
- var c: u16 = 0;
- while (c < config.PREFIX_W and c < tw) : (c += 1) {
- const cell = s.at(tx + c, body_y + vr);
- cell.default = false; // paints blank gutter rows too
- if (on_cursor) {
- cell.style.fg = .{ .rgb = ch.tag_fg };
- cell.style.bg = .{ .rgb = ch.tag_bg };
- } else cell.style.fg = .{ .rgb = ch.lineno };
- }
- }
-}
-
-const SynStyle = struct { fg: [3]u8, bold: bool };
-
-fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
- return switch (sy) {
- .none => null,
- .keyword => .{ .fg = p.theme().kw, .bold = true },
- .string => .{ .fg = p.theme().str, .bold = false },
- .number => .{ .fg = p.theme().num, .bold = false },
- .comment => .{ .fg = p.theme().comment, .bold = true },
- };
-}
-
-/// syntax colors: recolor each content cell from its tree-sitter style byte;
-/// content starts after the lineno gutter
-pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
- if (f.highlights.len == 0) return;
- const s = &p.surface;
- const tz_recolor = tracy.zone(@src(), "synRecolor");
- defer tz_recolor.end();
- // indexed start, same as bodyText — an empty tail simply paints nothing
- var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n');
- const total = nlines(p.gpa, f);
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
- var vr: u16 = 0;
- while (vr < body_h) : (vr += 1) {
- // A colour has to land on the byte it belongs to, so this walk reads
- // the same map the body was built from: wrapped, the screen row names
- // its own line and the byte column it began at, and it ends where the
- // NEXT row of that line begins. Unwrapped the rows ARE the lines in
- // order and the split iterator is the cheaper walk.
- var base: usize = undefined;
- var line: []const u8 = undefined;
- var hs: usize = @intCast(@max(0, pane.hscroll));
- var limit: usize = undefined;
- if (pane.wrap_n == 0) {
- line = flines.next() orelse break;
- base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr);
- limit = line.len;
- } else {
- if (vr >= pane.wrap_n) break;
- const lrow: usize = @intCast(@max(0, pane.wrap_line[vr]));
- if (lrow >= total) break;
- base = lineStart(p.gpa, f, lrow);
- const lend = if (lrow + 1 < total) lineStart(p.gpa, f, lrow + 1) -| 1 else f.content.len;
- line = f.content[base..lend];
- hs = @intCast(pane.wrap_col[vr]);
- limit = if (vr + 1 < pane.wrap_n and pane.wrap_line[vr + 1] == pane.wrap_line[vr])
- @min(line.len, @as(usize, @intCast(pane.wrap_col[vr + 1])))
- else
- line.len;
- }
- hs = modal.graphemeStart(line, @min(hs, line.len));
- var c: usize = 0;
- var screen_c: usize = 0;
- while (hs + c < limit and config.PREFIX_W + screen_c < tw) {
- const grapheme_end = @min(limit, modal.nextGrapheme(line, hs + c));
- const cells = graphemeDisplayWidth(line[hs + c .. grapheme_end]);
- const idx = base + hs + c;
- if (idx >= f.highlight_start) {
- const hidx = idx - f.highlight_start;
- if (hidx < f.highlights.len) {
- if (synStyle(p, @enumFromInt(f.highlights[hidx]))) |ss| {
- var fill: usize = 0;
- while (fill < cells and config.PREFIX_W + screen_c + fill < tw) : (fill += 1) {
- const cell = s.at(tx + @as(u16, @intCast(config.PREFIX_W + screen_c + fill)), body_y + vr);
- if (cell.default) continue;
- cell.style.fg = .{ .rgb = ss.fg };
- cell.style.bold = ss.bold;
- }
- }
- }
- }
- screen_c += cells;
- c = grapheme_end - hs;
- }
- }
-}
-
-/// Mark every visible wrapped row which continues onto the next screen row.
-/// This is file chrome: it follows syntax recoloring and precedes the shared
-/// selection passes, so neither source ink nor the marker can win over a user
-/// selection.
-pub fn drawWrapMarkers(
- p: *Pardes,
- pane: *const Pane,
- r: pardes.Rect,
- tx: u16,
- tw: u16,
- body_h: u16,
- pane_bg: pardes.Color,
-) void {
- if (tw <= config.PREFIX_W + 1) return;
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
- const marker_fg = p.chromeTheme().lineno;
- var row: u16 = 0;
- while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) {
- if (pane.wrap_line[row + 1] != pane.wrap_line[row]) continue;
- p.surface.set(tx + tw - 1, body_y + row, config.wrap_marker, .{
- .fg = .{ .rgb = marker_fg },
- .bg = pane_bg,
- });
- }
-}
-
-/// Paint one logical file word through the last frame's wrap map. Unlike a
-/// rectangular mouse selection, a path may cross continuation rows without
-/// highlighting unrelated cells between its endpoints.
-pub fn paintWordSelection(
- p: *Pardes,
- pane: *Pane,
- r: pardes.Rect,
- row: i32,
- word_lo: i32,
- word_hi: i32,
- bg: [3]u8,
-) void {
- const tx = r.x + config.GUTTER;
- const tw = r.w - config.GUTTER;
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
- var vr: i32 = 0;
- while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) {
- const here = pane.wrapAt(vr);
- if (here.line != row) continue;
- var hi = word_hi;
- const next = pane.wrapAt(vr + 1);
- if (next.line == row) hi = @min(hi, next.at);
- const lo = @max(word_lo, here.at);
- if (hi <= lo) continue;
- const c0 = @as(i32, config.PREFIX_W) + displayOffset(pane, row, here.at, lo);
- const c1 = @as(i32, config.PREFIX_W) + displayEndOffset(pane, row, here.at, hi - 1);
- var col = @max(@as(i32, config.PREFIX_W), c0);
- while (col <= c1 and col < @as(i32, tw)) : (col += 1) {
- const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr)));
- cell.default = false;
- cell.style.bg = .{ .rgb = bg };
- }
- }
-}