//! File panes: everything a Pane does BECAUSE it has `file: ?File` 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 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 syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; 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 File /// 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: *File) ![]const usize { if (f.line_starts.len > 0) return f.line_starts; // exact alloc, not an ArrayList: deinitPane frees `line_starts` itself, and // an ArrayList's items slice is shorter than its allocation whenever the // growth strategy rounded up — freeing that is a wrong-size free. 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: *File) 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 { 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 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; } /// 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 { p.gpa.free(f.content); f.content = new; 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.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: *File, snap: FileSnap) 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(); } /// 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.colors_on) { if (f.highlights.len > 0) p.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 = syntax.highlightFileRange(p.gpa, f.path, f.content, start, end) catch { f.syntax_dirty = false; continue; }; if (f.highlights.len > 0) p.gpa.free(f.highlights); f.highlights = new_highlights; f.highlight_start = if (f.highlights.len > 0) start else 0; f.syntax_dirty = false; } } /// the body a file pane renders: `pane.rows` content lines from the scroll /// offset, each behind its right-aligned line number, then cut by hscroll. pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File) ![]const u8 { var out: std.ArrayList(u8) = .empty; // 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(); var i: usize = 0; while (i < pane.rows) : (i += 1) { if (i > 0) try out.append(arena, '\n'); const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i)); const row_start = out.items.len; if (flines.next()) |text| { var lbuf: [16]u8 = undefined; // unsigned: {d} prints a leading '+' for signed ints const lineno: usize = @intCast(abs + 1); const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " "; var base: std.ArrayList(u8) = .empty; try base.appendSlice(arena, prefix); try base.appendSlice(arena, text); try out.appendSlice(arena, base.items); } // horizontal scroll: cut hscroll columns after the gutter if (pane.hscroll > 0) { const cut_at = row_start + config.PREFIX_W; if (out.items.len > cut_at) { const cut = @min(@as(usize, @intCast(pane.hscroll)), out.items.len - cut_at); out.replaceRangeAssumeCapacity(cut_at, cut, &.{}); } } } return out.items; } /// 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 /// 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 th = p.theme(); const goff = pane.scroll(); const gcur = pane.vt.screens.active.cursor; const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(gcur.y)) + goff; const cur_vr: i32 = if (active and !pane.tag_edit) gcrow - goff else -1; var vr: u16 = 0; while (vr < body_h) : (vr += 1) { const on_cursor = @as(i32, vr) == cur_vr; var c: u16 = 0; while (c < config.PREFIX_W and c < tw) : (c += 1) { const cell = s.at(tx + c, r.y + pardes.BOX_H + vr); cell.default = false; // paints blank gutter rows too if (on_cursor) { cell.style.fg = .{ .rgb = th.tag_fg }; cell.style.bg = .{ .rgb = th.tag_bg }; } else cell.style.fg = .{ .rgb = th.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: *File, 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 hs: usize = @intCast(@max(0, pane.hscroll)); var vr: u16 = 0; while (vr < body_h) : (vr += 1) { const line = flines.next() orelse break; const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); var c: usize = 0; while (hs + c < line.len and config.PREFIX_W + c < tw) : (c += 1) { const idx = base + hs + c; if (idx < f.highlight_start) continue; const hidx = idx - f.highlight_start; if (hidx >= f.highlights.len) continue; const ss = synStyle(p, @enumFromInt(f.highlights[hidx])) orelse continue; const cell = s.at(tx + @as(u16, @intCast(config.PREFIX_W + c)), r.y + pardes.BOX_H + vr); if (cell.default) continue; cell.style.fg = .{ .rgb = ss.fg }; cell.style.bold = ss.bold; } } }