diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-30 23:35:45 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 19f7322100062b7c1adcde3376063ce6c1d8c72d (patch) | |
| tree | b76347dffe0347c29a38b4cad6031678683dba74 /src/builtins.zig | |
| parent | 753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff) | |
| download | pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.tar.gz pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.zip | |
structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into their own files
The core now lies FLAT at src/ and every subdirectory is one backend, so a
file being in no directory at all is what says it is core. Pane-kind bodies
leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving
it the layout, the event/effect machine and the generic render loop.
Builtins are one struct each in builtins.zig, and the enum is folded out of
the file's own declaration list at comptime — a zig file IS a struct, so the
list of builtins and the builtins themselves are the same text. Adding one is
writing a struct. Key paths deliberately stay one table for the config pass.
Pure refactor: no golden moved.
Diffstat (limited to 'src/builtins.zig')
| -rw-r--r-- | src/builtins.zig | 443 |
1 files changed, 443 insertions, 0 deletions
diff --git a/src/builtins.zig b/src/builtins.zig new file mode 100644 index 00000000..7143a860 --- /dev/null +++ b/src/builtins.zig @@ -0,0 +1,443 @@ +//! The builtins: one struct each, and nothing hand-maintained about them. +//! +//! Executing a builtin's NAME (middle-click / Tab) runs it through the one +//! dispatcher (Pardes.runBuiltin), no matter where the name appears. The +//! struct's DECL NAME is the user-visible word — the one in the topbar, the +//! one sitting in a tag, the one Help prints, the one you execute — so +//! `std.meta.stringToEnum` is the lookup and there is no name table to sync. +//! +//! A zig file IS a struct, so THIS FILE'S declarations are the list: `all` +//! walks them at comptime and `Builtin` folds an enum out of that, in source +//! order. Adding a builtin is writing one struct here — there is no list to +//! append to and no switch prong to add, so there is nothing to forget. The +//! hand-written enum and the four-hundred-line-away switch this replaces were +//! two lists that had to agree; now the code IS the data. +//! +//! What makes a declaration a builtin is its SHAPE: a struct declaring +//! `pub fn run(Ctx) void`. That is strict enough that Ctx and the two folds +//! below fall out by construction rather than by a blocklist — a helper can +//! never accidentally become a builtin, and a builtin whose run has the wrong +//! signature silently vanishes instead of half-working, which the leader +//! table catches immediately (see leader_path: a missing key path is a +//! compile error). +//! +//! What is NOT here: the key bindings. `leader_path` stays ONE table in +//! pardes.zig next to `topbar_str` — the whole remapping surface belongs in +//! one place a user can read top to bottom, not scattered a line at a time +//! across thirty-three structs. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const output_pane = @import("output_pane.zig"); + +/// What a builtin gets to act on. One bundle rather than five parameters +/// because most builtins want two of them and zig rejects the unused rest. +/// `txt` is the executed text (Restore reads its path back out of it) and +/// `arg` the execute's ARGUMENT — text typed after the name, or the selection +/// a mouse chord kept, which is why Grep and Find run straight away when there +/// is one instead of asking. The leader passes "" and null: a key path names a +/// builtin, never an argument. +pub const Ctx = struct { + p: *Pardes, + /// pane `id`, already resolved — the dispatcher's null check is the one + /// guard every builtin used to share. + pane: *Pane, + id: usize, + txt: []const u8, + arg: ?[]const u8, +}; + +/// Every builtin, in the order they are written below — which is the enum's +/// numeric order. Nothing reads that order: the topbar picks its own subset by +/// name and Help sorts by key path, and nothing persists a builtin's integer +/// (the dump stores tag WORDS), so reordering this file is free. +/// +/// A FUNCTION and not a const, and so is Builtin below, for one reason: both +/// walk this file's own declaration list, and a const doing that is a +/// declaration whose value depends on itself — zig rejects it outright. As +/// functions they are only ever a signature to the walk, never a value, so +/// they are not in their own way. +/// +/// The walk only ever sees `pub` decls, so the imports above are invisible to +/// it; what it does see and turn away is `all`/`word`/`Builtin` (not types) +/// and `Ctx` (a type, but with no `run`). +pub fn all() []const type { + // the whole body is comptime: a []const type only exists there, and it is + // what makes the decl walk's `d` a compile-time name rather than a value + comptime { + @setEvalBranchQuota(4000); // one pass per decl, and @hasDecl builds a map each + var list: []const type = &.{}; + for (@typeInfo(@This()).@"struct".decls) |d| { + // @TypeOf never evaluates its operand, so this turns away `all` + // and `Builtin` by their SIGNATURES — asking for either one's + // VALUE here would be a declaration that depends on itself + if (@TypeOf(@field(@This(), d.name)) != type) continue; + const T = @field(@This(), d.name); + if (@typeInfo(T) != .@"struct") continue; + if (!@hasDecl(T, "run")) continue; + if (@TypeOf(T.run) != fn (Ctx) void) continue; + list = list ++ &[_]type{T}; + } + return list; + } +} + +/// A builtin's user-visible word: the LAST dotted segment of `@typeName`, +/// because @typeName spells a file-scope struct fully qualified +/// ("builtins.Kill"). Deriving it beats a `pub const name` field per struct, +/// which would be the same word written twice with nothing keeping the two +/// honest. A name that is not a legal identifier would be spelled `@"..."`. +pub fn word(comptime T: type) []const u8 { + const n = @typeName(T); + const dot = std.mem.lastIndexOfScalar(u8, n, '.') orelse return n; + return n[dot + 1 ..]; +} + +/// The enum: field name = struct name, value = index into `all()`. Everything +/// downstream (leader_path's EnumArray, leader_rows, the topbar's comptime +/// check, stringToEnum) speaks it exactly as it did when it was hand-written. +pub fn Builtin() type { + const cmds = all(); + const Tag = std.math.IntFittingRange(0, cmds.len - 1); + var names: [cmds.len][]const u8 = undefined; + for (cmds, 0..) |T, i| names[i] = word(T); + return @Enum(Tag, .exhaustive, &names, &std.simd.iota(Tag, cmds.len)); +} + +// ---- session ---- + +pub const Kill = struct { + pub fn run(c: Ctx) void { + c.p.quit = true; + c.p.emit(.quit); + } +}; + +pub const Dump = struct { + pub fn run(c: Ctx) void { + c.p.dumpState() catch {}; + } +}; + +pub const Restore = struct { + pub fn run(c: Ctx) void { + var it = std.mem.tokenizeAny(u8, c.txt, " \t"); + _ = it.next(); // the word "Restore" + const path = it.next() orelse (c.p.last_dump orelse return); + if (path.len > c.p.restore_buf.len) return; + @memcpy(c.p.restore_buf[0..path.len], path); + c.p.restore_req = c.p.restore_buf[0..path.len]; + } +}; + +// ---- display toggles ---- + +pub const Debug = struct { + pub fn run(c: Ctx) void { + c.p.show_debug = !c.p.show_debug; + } +}; + +pub const Colors = struct { + pub fn run(c: Ctx) void { + c.p.colors_on = !c.p.colors_on; + } +}; + +pub const NextColor = struct { + pub fn run(c: Ctx) void { + c.p.theme_idx = (c.p.theme_idx + 1) % pardes.themes.len; + } +}; + +pub const Crt = struct { + pub fn run(c: Ctx) void { + c.p.crt_on = !c.p.crt_on; + } +}; + +// The image pane's three renderer toggles. They used to be words the image tag +// printed and actOnSelection matched by hand; as builtins they are executable +// anywhere, pressable under SPC and listed by `SPC ?`, which is the whole +// reason the tag no longer carries them. Each acts on the pane it runs in and +// is inert anywhere else, the way Save is on a terminal — flipping the field is +// the whole toggle: drawImage re-matches the glyph grid when it sees +// grid_mode/grid_ascii disagree with the live ones. + +/// glyph art over the host's pixels +pub const Petscii = struct { + pub fn run(c: Ctx) void { + if (c.pane.image) |*iv| { + iv.petscii = !iv.petscii; + } + } +}; + +/// the C64 palette or the terminal's own 16 +pub const Palette = struct { + pub fn run(c: Ctx) void { + if (c.pane.image) |*iv| { + iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore; + } + } +}; + +/// add the printable ASCII bitmaps to the matcher's glyph set +pub const Ascii = struct { + pub fn run(c: Ctx) void { + if (c.pane.image) |*iv| { + iv.ascii = !iv.ascii; + } + } +}; + +// ---- panes and columns ---- + +pub const Save = struct { + pub fn run(c: Ctx) void { + // an output buffer has no file behind it — nothing to write + if (c.pane.file) |f| if (!f.output) c.p.emit(.{ .save_file = .{ .pane = @intCast(c.id) } }); + } +}; + +pub const Newcol = struct { + pub fn run(c: Ctx) void { + const free = c.p.freeSlot() orelse return; + if (c.p.ncol >= pardes.MAX_COLS) return; + const nt = c.p.newShell(free, "") catch return; + nt.greet = true; + c.p.layoutAppendColumn(free); + c.p.active = free; + } +}; + +pub const Del = struct { + pub fn run(c: Ctx) void { + c.p.absorbVWeight(c.id); + c.p.layoutRemove(c.id); + c.p.deinitPane(c.pane); + c.p.panes[c.id] = null; + if (c.p.active == c.id) c.p.active = c.p.prevFocus(c.id) orelse { + c.p.quit = true; + c.p.emit(.quit); + return; + }; + } +}; + +pub const Delcol = struct { + pub fn run(c: Ctx) void { + const f = c.p.layoutFindTerm(c.id) orelse return; + var ids: [pardes.MAX_PANES]usize = undefined; + const nids = c.p.col_n[f.col]; + for (0..nids) |k| ids[k] = c.p.col_terms[f.col][k]; + for (ids[0..nids]) |tid| { + if (c.p.panes[tid]) |tt| { + c.p.layoutRemove(tid); + c.p.deinitPane(tt); + c.p.panes[tid] = null; + } + } + if (c.p.panes[c.p.active] == null) c.p.active = c.p.prevFocus(c.p.active) orelse { + c.p.quit = true; + c.p.emit(.quit); + return; + }; + } +}; + +pub const Tutor = struct { + pub fn run(c: Ctx) void { + const free = c.p.freeSlot() orelse return; + const nt = c.p.openTutorView(free) catch return; + c.p.placeDoc(c.id, free, nt); // a doc like any other + } +}; + +pub const Help = struct { + pub fn run(c: Ctx) void { + output_pane.openHelp(c.p, c.id, ""); + } +}; + +// ---- search ---- + +// The two builtins that ASK for something — Find walks file NAMES under this +// pane's directory, Grep file CONTENTS under every pane's. With an argument +// there is nothing to ask: it IS the pattern, so the walk runs now (this is +// what a `Grep` executed with a selection chorded to it means). Without one +// they arm the same tag input `/` does, and Enter runs it (submitSearch). + +pub const Find = struct { + pub fn run(c: Ctx) void { + const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + if (pat.len > 0) return c.p.runSearch(c.id, pat, .find); + c.p.startSearch(c.pane, Pardes.find_marker); + } +}; + +/// Find's sibling: Find matches file NAMES under this pane's directory, Grep +/// matches file CONTENTS under every pane's directory at once. +pub const Grep = struct { + pub fn run(c: Ctx) void { + const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + if (pat.len > 0) return c.p.runSearch(c.id, pat, .grep); + c.p.startSearch(c.pane, Pardes.grep_marker); + } +}; + +// ---- the window group ---- + +// The DESTINATION is the name — a word, the way a tag holds Del or Save — +// because these names live in the same vocabulary as everything else here: +// `Wh` would be a leader key path leaking into the text you can middle-click. +// Plain English words are safe for exactly these five: focus is the cheapest +// thing to change by accident (nothing is edited, closed or written) and the +// way back is the opposite word. + +pub const Left = struct { + pub fn run(c: Ctx) void { + c.p.focusDir(c.id, .left); + } +}; + +pub const Down = struct { + pub fn run(c: Ctx) void { + c.p.focusDir(c.id, .down); + } +}; + +pub const Up = struct { + pub fn run(c: Ctx) void { + c.p.focusDir(c.id, .up); + } +}; + +pub const Right = struct { + pub fn run(c: Ctx) void { + c.p.focusDir(c.id, .right); + } +}; + +/// The file<->terminal hop. "Latest" is already recorded: focus_hist is the +/// MRU sync() rebuilds every update (most recent last), the same list a +/// closing pane hands focus back through — so this walks it instead of keeping +/// a second one. Which side is which: only a shell is a terminal; a file, an +/// image and an output buffer (+Search/+Help) are all DOCS you read, so +/// isTerminal is the whole test. Landing pushes this pane to the top of that +/// same history, which is why the hop back is the same key. +pub const Toggleterm = struct { + pub fn run(c: Ctx) void { + const want_term = !c.pane.isTerminal(); + var t: ?usize = null; + // any live pane of the other kind: a pane you have never focused (the + // file you started with) is in no history at all + for (c.p.panes, 0..) |slot, k| { + const op = slot orelse continue; + if (k != c.id and op.isTerminal() == want_term) t = k; + } + // ...but the most recently focused one wins + var i = c.p.nfocus; + while (i > 0) { + i -= 1; + const hid = c.p.focus_hist[i]; + const hp = c.p.panes[hid] orelse continue; + if (hid != c.id and hp.isTerminal() == want_term) { + t = hid; + break; + } + } + if (t) |target| { + c.p.active = target; + c.p.panes[target].?.pending = 0; + } + } +}; + +// ---- the language group ---- + +// Reached as `SPC l <helix's letter>` — see leader_path for why the prefix +// exists. They are builtins rather than bare keys for the same reason Save is +// one: the word is executable wherever it appears, so a middle-click on +// `Hover` in a tag does what `SPC l k` does. The five GOTOS are not here — +// helix binds them under `g` as motions, and a motion has no business being a +// word you can click. +// +// Most of them are one call: ask, and let the answer land in lspResponse. +// Nothing here blocks or knows what a backend is — swapping backends changes +// lsp.query and not one line below. + +pub const Hover = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .hover, ""); + } +}; + +pub const CodeAction = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .code_action, ""); + } +}; + +pub const SelectRefs = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .select_refs, ""); + } +}; + +pub const Symbols = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .document_symbols, ""); + } +}; + +pub const Diagnostics = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .diagnostics, ""); + } +}; + +pub const WsDiagnostics = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .workspace_diagnostics, ""); + } +}; + +// The two that need a word from the user, handled exactly the way Find and +// Grep are: an argument means run it now (a selection chorded onto the name), +// no argument arms the tag input and Enter submits (submitSearch). + +pub const Rename = struct { + pub fn run(c: Ctx) void { + const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + if (a.len > 0) return c.p.lspRequest(c.id, .rename, a); + c.p.startSearch(c.pane, Pardes.rename_marker); + } +}; + +pub const WsSymbols = struct { + pub fn run(c: Ctx) void { + const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + if (a.len > 0) return c.p.lspRequest(c.id, .workspace_symbols, a); + c.p.startSearch(c.pane, Pardes.symbol_marker); + } +}; + +// Introspection. A language backend that answers nothing looks exactly like +// one that is broken — from the outside, `gd` doing nothing is both "there is +// no definition" and "the analyser threw and we swallowed it". These two are +// how you tell: Lspinfo says what the backend IS, Lspwhy says what it just DID +// and where it stopped. + +pub const Lspinfo = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .status, ""); + } +}; + +pub const Lspwhy = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .explain, ""); + } +}; |
