summaryrefslogtreecommitdiff
path: root/src/file_pane.zig
blob: 2f2a0acb3a9cd91911d86c2fc68df7952bd9fbac (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
//! 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;
    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.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;
        }
    }
}