diff options
Diffstat (limited to 'src/file_pane.zig')
| -rw-r--r-- | src/file_pane.zig | 1059 |
1 files changed, 0 insertions, 1059 deletions
diff --git a/src/file_pane.zig b/src/file_pane.zig deleted file mode 100644 index 9b828030..00000000 --- a/src/file_pane.zig +++ /dev/null @@ -1,1059 +0,0 @@ -//! 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 limits = @import("limits.zig"); - -const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; - -/// 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: [limits.undo_max]Snapshot = undefined, - undo_len: usize = 0, - redo: [limits.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}🚀")); -} - -test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" { - // The arm claims a one-byte printable ASCII grapheme is one cell without asking `gwidth`. That - // is only worth having if the two never disagree, so ask both for every byte the arm can see - - // including \t, \r, the rest of the C0 controls and DEL, which the range test excludes and - // which must therefore still come back from `gwidth` (or, for the tab, from the config). - const ref = struct { - fn width(grapheme: []const u8) usize { - if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; - return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); - } - }.width; - - var one: [1]u8 = undefined; - var b: u8 = 0; - while (b < 0x80) : (b += 1) { - one[0] = b; - try std.testing.expectEqual(ref(one[0..1]), graphemeDisplayWidth(one[0..1])); - } - // Multi-byte clusters never reach the arm (len != 1), so they pin that it does not widen its - // claim: a combining sequence and a ZWJ emoji are one and two cells, a CJK glyph is two, and - // an invalid byte is the one cell `gwidth` reports for U+FFFD-shaped input. - for ([_][]const u8{ - "e\u{301}", "a\u{903}", "1\u{fe0f}\u{20e3}", "\u{4e16}", - "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", - }) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g)); -} - -test "the ASCII run in fitEnd survives an exhaustive byte sweep" { - // The case list in the test above is hand-picked; this one is not. Every byte 0x00..0x7f is - // placed next to every neighbour that can change the answer - a combining mark, a ZWJ - // sequence, a spacing mark, a variation selector, a wide glyph, and a bad start byte, a - // truncated tail and a bad continuation - and every break column is compared against the - // grapheme walk. An off-by-one column here moves text between wrapped rows, so equality is - // exact, not approximate. - 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 neighbours = [_][]const u8{ - "", "z", "\u{301}", "\u{200d}\u{1f680}", - "\u{903}", "\u{fe0f}", "\u{4e16}", "\u{1f642}", - "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", "\xe4\x28\xb8", - }; - var buf: [16]u8 = undefined; - // The same pair again behind an ASCII prefix, so a break can land exactly at the run boundary - // as well as before it and inside the multi-byte cluster that follows it. - var prefixed: [18]u8 = undefined; - prefixed[0] = 'a'; - prefixed[1] = 'b'; - var b: u8 = 0; - while (b < 0x80) : (b += 1) { - buf[0] = b; - for (neighbours) |tail| { - @memcpy(buf[1..][0..tail.len], tail); - const pair = buf[0 .. 1 + tail.len]; - @memcpy(prefixed[2..][0..pair.len], pair); - for ([_][]const u8{ pair, prefixed[0 .. 2 + pair.len] }) |text| { - var width: usize = 0; - while (width <= text.len + 3) : (width += 1) { - var start: usize = 0; - while (start <= text.len) : (start += 1) { - std.testing.expectEqual( - reference(text, start, width), - fitEnd(text, start, width), - ) catch |e| { - std.debug.print("fitEnd({any}, {d}, {d})\n", .{ text, start, width }); - return e; - }; - } - } - } - } - } -} - -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; - // A RESULTS BUFFER IS COLOURED WHOLE, ONCE. Its rows are independent — - // each is parsed in isolation against its own grammar — so a window - // pass buys no fidelity, only amortisation, and pays for it with a - // burst on every scroll that outran the covered range: a fresh parser, - // a fresh query cursor and a tree per row, plus the first compile of - // any grammar the new rows introduce, all inside `render`. Colouring - // the whole buffer when it is FILLED makes the covered-range check - // above true forever after, so scrolling one costs nothing at all. - // Bounded by what fills them: `look.find_max_hits` caps a grep at 512 - // rows, and a rendering is never typed into. - const whole = pane.colorAlgo() == .locations; - const start = if (whole) 0 else lineStart(p.gpa, f, f.scroll -| slack); - const end = if (whole) - f.content.len - else - @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), - .locations => syntax.highlightLocations(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 }; - } - } -} |
