summaryrefslogtreecommitdiff
path: root/src/file_pane.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/file_pane.zig')
-rw-r--r--src/file_pane.zig196
1 files changed, 196 insertions, 0 deletions
diff --git a/src/file_pane.zig b/src/file_pane.zig
new file mode 100644
index 00000000..4c02e983
--- /dev/null
+++ b/src/file_pane.zig
@@ -0,0 +1,196 @@
+//! 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 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 + pardes.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 < pardes.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 pardes.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(pardes.PREFIX_W + c)), r.y + pardes.BOX_H + vr);
+ if (cell.default) continue;
+ cell.style.fg = .{ .rgb = ss.fg };
+ cell.style.bold = ss.bold;
+ }
+ }
+}