summaryrefslogtreecommitdiff
path: root/src/builtins.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-30 23:35:45 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit19f7322100062b7c1adcde3376063ce6c1d8c72d (patch)
treeb76347dffe0347c29a38b4cad6031678683dba74 /src/builtins.zig
parent753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff)
downloadpardes-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.zig443
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, "");
+ }
+};