summaryrefslogtreecommitdiff
path: root/src/file_pane.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789 /src/file_pane.zig
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz
pardes-1551e409c31992437cb2fa864f576d45c8433801.zip
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/file_pane.zig')
-rw-r--r--src/file_pane.zig388
1 files changed, 371 insertions, 17 deletions
diff --git a/src/file_pane.zig b/src/file_pane.zig
index b0921bd0..cf2b9571 100644
--- a/src/file_pane.zig
+++ b/src/file_pane.zig
@@ -1,4 +1,4 @@
-//! File panes: everything a Pane does BECAUSE it has `file: ?File` set — the
+//! 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
@@ -8,14 +8,79 @@ const pardes = @import("pardes.zig");
const config = @import("config.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
-const File = pardes.File;
-const FileSnap = pardes.FileSnap;
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 dump = @import("dump.zig");
const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
+const undo_max = 256;
+
+/// 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 handed to Save. 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: [undo_max]Snapshot = undefined,
+ undo_len: usize = 0,
+ redo: [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 byteDisplayWidth(byte: u8) usize {
return if (byte == '\t') config.tab_width else 1;
@@ -39,6 +104,67 @@ pub fn byteAtDisplay(text: []const u8, display_col: usize) usize {
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 = @min(raw_col, line_text.len);
+ return displayWidth(line_text[0..bounded]) +| (raw_col - bounded);
+}
+
+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 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));
+ const from_display = rawDisplayCol(line_text, from);
+ const to_display = rawDisplayCol(line_text, to);
+ if (to_display >= from_display) return @intCast(to_display - from_display);
+ return -@as(i32, @intCast(from_display - to_display));
+}
+
+pub fn displayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 {
+ const start = displayOffset(pane, row, from_raw, at_raw);
+ const line_text = sourceLine(pane, row);
+ const at: usize = @intCast(@max(0, at_raw));
+ if (at >= line_text.len) return start;
+ return start + @as(i32, @intCast(byteDisplayWidth(line_text[at]))) - 1;
+}
+
+pub fn byteAtRowDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 {
+ const line_text = sourceLine(pane, row);
+ const from: usize = @intCast(@max(0, from_raw));
+ const display: usize = @intCast(@max(0, display_col));
+ if (from >= line_text.len) return @intCast(from +| display);
+ return @intCast(from +| rawAtDisplay(line_text[from..], display));
+}
+
+/// Convert between rendered pane rows (whose file body includes PREFIX_W)
+/// and source-byte columns. Non-file rows are already in byte coordinates.
+pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize {
+ if (pane.file == null or row < pardes.BOX_H) return 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 (pane.file == null or row < pardes.BOX_H) return 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);
+}
+
fn fitEnd(text: []const u8, start: usize, width: usize) usize {
var end = start;
var used: usize = 0;
@@ -64,7 +190,7 @@ pub fn lineCount(content: []const u8) usize {
/// 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 File
+/// 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
@@ -74,7 +200,7 @@ pub fn lineCount(content: []const u8) usize {
/// 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: *File) ![]const usize {
+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.
@@ -92,18 +218,65 @@ pub fn lineIndex(gpa: std.mem.Allocator, f: *File) ![]const usize {
}
/// line count, O(1) once the index is warm
-pub fn nlines(gpa: std.mem.Allocator, f: *File) usize {
+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: *File, row: usize) usize {
+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 @min(start + cursor.col, end);
+}
+
+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;
+ return .{ .row = row, .col = bounded - index[row] };
+}
+
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);
@@ -113,7 +286,6 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
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.kind = .file;
pane.cur_pinned = true;
pane.cur_row = @intCast(scroll);
// watches follow pane lifetime: this is the only place a real file is read
@@ -122,9 +294,56 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
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);
+}
+
/// 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.
-pub fn setContent(p: *Pardes, f: *File, new: []u8) void {
+pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
p.gpa.free(f.content);
f.content = new;
f.revision +%= 1;
@@ -142,7 +361,7 @@ pub fn setContent(p: *Pardes, f: *File, new: []u8) void {
/// 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: *File, snap: FileSnap) void {
+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;
@@ -155,6 +374,80 @@ pub fn restoreSnap(pane: *Pane, f: *File, snap: FileSnap) void {
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);
+ // 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 {
@@ -165,7 +458,7 @@ pub fn refreshHighlights(p: *Pardes) void {
if (pane.file == null) continue;
const f = &pane.file.?;
if (!f.syntax_dirty) continue;
- if (!p.colors_on) {
+ if (!p.settings.colors) {
if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
f.highlights = &.{};
f.highlight_start = 0;
@@ -222,7 +515,7 @@ pub fn refreshHighlights(p: *Pardes) void {
/// 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: *File, wrap: bool) ![]const u8 {
+pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *State, wrap: bool) ![]const u8 {
// the pane's own content width, less the ONE column the break marker sits
// in — a wrapped row that filled its last cell would have nowhere to say it
// wrapped. 0 means don't wrap. A pane taller than the map refuses too,
@@ -245,7 +538,7 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File, wrap: bool) ![]
/// 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: *File, width: usize, record_wrap: bool) usize {
+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
@@ -313,7 +606,7 @@ fn fillBody(dst: ?[]u8, pane: *Pane, f: *File, width: usize, record_wrap: bool)
}
/// line-number gutter: mute the first PREFIX_W columns. Cheap chrome, not
-/// gated on colors_on; selection/cursor passes still win. The cursor row's
+/// 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 {
@@ -327,7 +620,7 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod
// 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.tag_bottom) r.y else r.y + pardes.BOX_H;
+ 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)
@@ -360,7 +653,7 @@ fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
/// 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: *File, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
+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");
@@ -368,7 +661,7 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16,
// 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.tag_bottom) r.y else r.y + pardes.BOX_H;
+ 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
@@ -420,3 +713,64 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16,
}
}
}
+
+/// 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 };
+ }
+ }
+}