//! 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 SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; /// EDIT BOUNDARIES REMEMBERED PER FILE PANE. Every entry owns a gpa copy of /// the WHOLE file, so this number multiplies heap, not just the pane: 256 of /// them is not a bound a 384 KiB board could ever reach anyway. `pushHistory` /// evicts and frees the oldest once full, so the smaller ring loses the /// deepest undo steps and nothing else — no truncation, no dropped edit. const undo_max = if (@import("pardes_config").platform == .p4) 16 else 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 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: [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 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}🚀")); } 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; const start = lineStart(p.gpa, f, f.scroll -| slack); const end = @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), 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 }; } } }