//! 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_BEFORE_ROWS: usize = 0; const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; pub fn lineCount(content: []const u8) usize { return std.mem.count(u8, content, "\n") + 1; } 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); return pane; } pub fn setContent(p: *Pardes, f: *File, new: []u8) void { p.gpa.free(f.content); f.content = new; 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 = modal.lineCount(f.content); const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), if (n == 0) 0 else 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; } const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS; const start = modal.lineStartOffset(f.content, start_row); const end_row = start_row + SYNTAX_CONTEXT_BEFORE_ROWS + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS; const end = @max(start, modal.lineStartOffset(f.content, end_row)); 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; var flines = std.mem.splitScalar(u8, f.content, '\n'); var skipped: usize = 0; while (skipped < f.scroll) : (skipped += 1) { if (flines.next() == null) break; } 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(); var flines = std.mem.splitScalar(u8, f.content, '\n'); var skip: usize = 0; while (skip < f.scroll) : (skip += 1) { if (flines.next() == null) break; } 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; } } }