summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 02:40:11 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commit7022d738dba00049e820e969cabc34ecbeaea465 (patch)
tree28d32929255ba21144d579e210a658f6c1fb1943 /src
parent0bfa4615988c0d1bcaa38453375760c60076c5ea (diff)
downloadpardes-7022d738dba00049e820e969cabc34ecbeaea465.tar.gz
pardes-7022d738dba00049e820e969cabc34ecbeaea465.zip
Help lists every builtin and every way to reach it
Not a second builtin: Help already rendered "every builtin with a leader path and the key that runs it", and the ask is "every builtin and every shortcut" — the same listing with three more rows and one more column. A new builtin would have been a second renderer over a superset of the same rows, and the two would drift the first time a column moved. The prefix filter is now the degenerate case of the index rather than a separate mode: SPC h ? reads the same array with prefix "h", and the path-less builtins fall out on their own because an empty path starts with nothing. The shortcut column is folded, never typed: chordName spells a chord from Key's OWN declarations, so a new special key names itself and an unnamed non-printable codepoint is a compile error instead of garbage; shortcuts() walks the cmd columns of window_keys and jump_keys, look_cmd/exec_cmd with their keys and buttons, and topbar_str. Retarget a binding in config.zig and this re-renders. So Look reads `enter, right-click`, Back reads `C-o`, Kill reads `topbar`, and Theme reads nothing at all — which is the information. Not a picker, deliberately: n/N stepping a list holding Kill, Del and Delcol would end the session. The names are live text, so middle-click the one you want. Alt-n, Alt-c, the tty toggle and the cut/paste chords cannot appear — they are inline handlers that reach no builtin, so there is no row to put them on. Make one a struct with a cmd column beside its binding and it appears for free. Three goldens moved, all of them the +Help buffer's contents.
Diffstat (limited to 'src')
-rw-r--r--src/output_pane.zig36
-rw-r--r--src/pardes.zig159
2 files changed, 148 insertions, 47 deletions
diff --git a/src/output_pane.zig b/src/output_pane.zig
index a0583506..9c004dff 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -123,6 +123,14 @@ pub fn traits(o: Origin) Traits {
// rows are `location text`, so n/N walk them
.search => .{ .name = config.search_buffer, .steps = true },
.cmd => |b| switch (b) {
+ // The builtin index. Neither steppable nor executable, and that is
+ // a decision rather than an omission: its rows are not locations
+ // (a look on `SPC` would go looking for a file called SPC), and
+ // `executes` — which is exactly what makes ThemeSel a picker —
+ // would mean walking the list runs Kill, Del and Delcol in turn.
+ // Wearing a theme is free; wearing Kill ends the session. The
+ // names in it are still live text, so middle-clicking the ONE you
+ // want does the picking, which is all a picker was for.
.Help => .{ .name = config.help_buffer },
// the focus history, one location per row: not a search, but the
// same kind of list, so n/N walk it and a row is a look target
@@ -303,24 +311,36 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, text: []const u8) void {
pane.search_row = null;
}
-/// The Help builtin: every builtin reachable from `prefix` (empty = all
-/// of them) with the keys that run it, written into an output buffer
-/// (acme's +Errors) — ordinary text, so the names in it are LIVE: middle-
-/// click `Tutor` there and the tutor opens. Reuses the open +Help buffer
-/// instead of piling panes up, and focus follows: you asked to read it.
+/// The Help builtin: THE INDEX of builtins — every one of them, and every way
+/// to run it — filtered to what `prefix` can still reach, written into an
+/// output buffer (acme's +Errors). Ordinary text, so the names in it are LIVE:
+/// middle-click `Tutor` there and the tutor opens. Reuses the open +Help
+/// buffer instead of piling panes up, and focus follows: you asked to read it.
+///
+/// ONE builtin and not two. The complete index and the mid-chord "what can
+/// `SPC h` still reach" are the same array (pardes.builtin_rows) read with a
+/// different prefix — the empty one matches every row, including the builtins
+/// SPC cannot reach at all, so the reference page IS the filter's degenerate
+/// case. A second builtin would have been a second renderer over a superset of
+/// these rows, and the two would have drifted the first time a column moved.
pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void {
const pane = p.panes[id] orelse return;
const arena = p.scratch.allocator();
var out: std.ArrayList(u8) = .empty;
if (prefix.len == 0) {
- out.appendSlice(arena, "pardes builtins - SPC then the keys,\nor execute the name anywhere\n\n") catch return;
+ // the header names the four KINDS of shortcut a row's columns can
+ // hold, because a blank column is only readable once you know what
+ // would have been in it
+ out.appendSlice(arena, "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n") catch return;
} else {
out.appendSlice(arena, "pardes builtins under SPC") catch return;
for (prefix) |c| out.appendSlice(arena, &[_]u8{ ' ', c }) catch return;
out.appendSlice(arena, "\n\n") catch return;
}
- for (pardes.leader_rows) |row| {
- if (!std.mem.startsWith(u8, row.path, prefix)) continue;
+ for (pardes.builtin_rows) |row| {
+ // a path-less builtin filters as the empty path: in the full listing
+ // (which starts with nothing) and out of every group
+ if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue;
out.appendSlice(arena, row.line) catch return;
out.append(arena, '\n') catch return;
}
diff --git a/src/pardes.zig b/src/pardes.zig
index 4d6556f3..47892108 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -85,53 +85,132 @@ const file_pane_tail = " " ++ config.file_pane_builtins_str;
// execute) and `std.meta.stringToEnum` is still the lookup with no name table
// to keep in sync. It lands here rather than in builtins.zig because a
// container cannot hold a decl folded out of its own decl list, and here it
-// sits with the other two comptime folds (leader_rows, the topbar check).
+// sits with the other two comptime folds (builtin_rows, the topbar check).
const Builtin = builtins.Builtin();
-/// The leader tree, flattened: one row per builtin SORTED BY PATH, so every
-/// prefix's subtree is a contiguous run — which is all the two runtime
-/// readers need. The matcher asks "exact hit? still a prefix of something?"
-/// and Help filters the same rows by the same prefix. A node-and-pointer trie
-/// for thirteen two-character paths would be ceremony. `line` is the Help
-/// rendering, padded here so Help is a filter over an array of lines.
-const Leader = struct { path: []const u8, cmd: Builtin, line: []const u8 };
-/// how many builtins HAVE a leader path — Look and Exec do not (their
-/// shortcuts are keys and buttons), so this is no longer the enum's own length
-const leader_n = blk: {
- var n: usize = 0;
- for (std.enums.values(Builtin)) |b| n += @intFromBool(config.leader_path.get(b) != null);
- break :blk n;
-};
-pub const leader_rows: [leader_n]Leader = blk: {
- // the insertion sort below is O(n²) comptime branches, and n grew with the
- // language group
- @setEvalBranchQuota(8000);
- var rows: [leader_n]Leader = undefined;
- // the widest spaced-out path ("h t" = 3), so the names line up
+/// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN
+/// declarations rather than a table beside them — a new special key names
+/// itself here, and a wrong name is impossible because there is only one. The
+/// rest is the printable character; anything else is a compile error, because
+/// a private-use codepoint cast to a byte would render as silent garbage in a
+/// listing nobody diffs.
+fn chordName(comptime c: config.Chord) []const u8 {
+ comptime {
+ const mods = (if (c.ctrl) "C-" else "") ++ (if (c.alt) "A-" else "") ++ (if (c.shift) "S-" else "");
+ for (@typeInfo(Key).@"struct".decls) |d| {
+ if (@TypeOf(@field(Key, d.name)) != u21) continue;
+ if (@field(Key, d.name) == c.cp) return mods ++ d.name;
+ }
+ if (c.cp < 0x20 or c.cp > 0x7e) @compileError("chord has no name for the index");
+ return mods ++ &[_]u8{@as(u8, @intCast(c.cp))};
+ }
+}
+
+/// Every way to run `b` that is NOT its leader path: the chords and buttons
+/// config binds to it, plus the topbar if it has a word up there (row 0 is a
+/// click target, and the only shortcut that works in tty mode where SPC
+/// belongs to the shell). Walked from config's own tables — window_keys' and
+/// jump_keys' `cmd` columns, look_cmd/exec_cmd, topbar_str — so retargeting a
+/// binding there re-renders here with nothing to keep in step.
+///
+/// What is NOT here, and cannot be: a chord that reaches no builtin. Alt-n,
+/// Alt-c, the tty toggle and the 1-2/1-3 cut/paste chords are inline handlers
+/// with no word to index by. The day one of them becomes a builtin — a struct
+/// in builtins.zig and a `cmd` column beside its binding — it appears here for
+/// free, which is the upgrade path rather than a special case here.
+fn shortcuts(comptime b: Builtin) []const u8 {
+ comptime {
+ @setEvalBranchQuota(20000);
+ var parts: []const []const u8 = &.{};
+ // both spellings of the four directional moves: the arrow is a real
+ // key someone presses, and the table carries it precisely so it is
+ // discoverable from the builtin as well as the other way round
+ for (config.window_keys) |wk| {
+ if (wk.cmd != b) continue;
+ // the prefix has one spelling and this is it
+ const pfx = chordName(config.window_prefix[0]) ++ " ";
+ parts = parts ++ &[_][]const u8{ pfx ++ chordName(wk.letter), pfx ++ chordName(wk.arrow) };
+ }
+ for (config.jump_keys) |jk| {
+ if (jk.cmd == b) parts = parts ++ &[_][]const u8{chordName(jk.chord)};
+ }
+ // the two acme verbs, a key and a mouse button each. Asked of the
+ // BINDING (look_cmd/exec_cmd point at a builtin) rather than of Look
+ // and Exec by name, so pointing look_cmd at Grep moves the row.
+ if (config.look_cmd == b) {
+ for (config.look_key) |k| parts = parts ++ &[_][]const u8{chordName(k)};
+ parts = parts ++ &[_][]const u8{@tagName(config.look_button) ++ "-click"};
+ }
+ if (config.exec_cmd == b) {
+ for (config.exec_key) |k| parts = parts ++ &[_][]const u8{chordName(k)};
+ parts = parts ++ &[_][]const u8{@tagName(config.exec_button) ++ "-click"};
+ }
+ var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' ');
+ while (it.next()) |w| {
+ if (std.mem.eql(u8, w, @tagName(b))) parts = parts ++ &[_][]const u8{"topbar"};
+ }
+ var out: []const u8 = "";
+ for (parts, 0..) |s, i| out = out ++ (if (i > 0) ", " else "") ++ s;
+ return out;
+ }
+}
+
+/// THE BUILTIN INDEX, flattened: one row per builtin — the leader path that
+/// runs it (null: SPC does not reach it), every other way to run it, and the
+/// Help line those are rendered into. SORTED BY PATH, so every prefix's
+/// subtree is a contiguous run, which is all the two runtime readers need. The
+/// matcher asks "exact hit? still a prefix of something?" and Help filters the
+/// same rows by the same prefix. A node-and-pointer trie for forty
+/// two-character paths would be ceremony.
+///
+/// A row per BUILTIN and no longer a row per leader PATH, which is what makes
+/// Help the complete index instead of a second builtin listing a superset of
+/// what Help lists. A path-less builtin (Look, Exec, Theme) is a row like any
+/// other and its empty key column is the information: SPC does not reach this
+/// one, the last column does — or nothing does, which is also worth knowing.
+/// The mid-chord filter is the SAME array read with a non-empty prefix, and it
+/// drops those rows by itself because an empty path starts with nothing.
+const Row = struct { path: ?[]const u8, cmd: Builtin, line: []const u8 };
+pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: {
+ // one pass per builtin per config table, and the insertion sort below is
+ // O(n²) comptime branches on top of it
+ @setEvalBranchQuota(200000);
+ var rows: [std.enums.values(Builtin).len]Row = undefined;
+ // the widest spaced-out path ("l D" = 3) and the widest name, so the three
+ // columns line up
var keyw: usize = 0;
+ var namew: usize = 0;
for (std.enums.values(Builtin)) |b| {
- const p = config.leader_path.get(b) orelse continue;
- keyw = @max(keyw, 2 * p.len - 1);
+ if (config.leader_path.get(b)) |p| keyw = @max(keyw, 2 * p.len - 1);
+ namew = @max(namew, @tagName(b).len);
}
- var n: usize = 0;
- for (std.enums.values(Builtin)) |b| {
- const path = config.leader_path.get(b) orelse continue;
+ for (std.enums.values(Builtin), 0..) |b, i| {
var keys: []const u8 = "";
- for (path, 0..) |c, k| {
- if (k > 0) keys = keys ++ " ";
- keys = keys ++ &[_]u8{c};
+ if (config.leader_path.get(b)) |path| {
+ keys = "SPC ";
+ for (path, 0..) |c, k| {
+ if (k > 0) keys = keys ++ " ";
+ keys = keys ++ &[_]u8{c};
+ }
}
- rows[n] = .{
- .path = path,
+ // the name column is only padded when something follows it: a row
+ // whose builtin has no other shortcut ends at the name, so the listing
+ // carries no trailing whitespace
+ const rest = shortcuts(b);
+ const named = @tagName(b) ++ (if (rest.len == 0) "" else (" " ** (namew - @tagName(b).len)) ++ " " ++ rest);
+ rows[i] = .{
+ .path = config.leader_path.get(b),
.cmd = b,
- .line = "SPC " ++ keys ++ (" " ** (keyw - keys.len)) ++ " " ++ @tagName(b),
+ .line = keys ++ (" " ** (4 + keyw - keys.len)) ++ " " ++ named,
};
- n += 1;
}
- // insertion sort by path: a group sorts right before what extends it
+ // insertion sort by path: a group sorts right before what extends it, and
+ // a path-less builtin sorts after every path — DEL is not a path because
+ // leaderKey only ever stores a printable key
+ const last = "\x7f";
for (1..rows.len) |i| {
var j = i;
- while (j > 0 and std.mem.lessThan(u8, rows[j].path, rows[j - 1].path)) : (j -= 1) {
+ while (j > 0 and std.mem.lessThan(u8, rows[j].path orelse last, rows[j - 1].path orelse last)) : (j -= 1) {
const t = rows[j];
rows[j] = rows[j - 1];
rows[j - 1] = t;
@@ -1819,11 +1898,13 @@ pub const Pardes = struct {
p.leader_keys[p.leader_n] = c;
p.leader_n += 1;
const pfx = p.leader_keys[0..p.leader_n];
- for (leader_rows) |row| {
- if (std.mem.eql(u8, row.path, pfx)) return p.runBuiltin(row.cmd, p.active, "", null);
+ // a path-less builtin is a row here too (it is the same index Help
+ // reads), and SPC cannot reach one — so both scans skip it
+ for (builtin_rows) |row| {
+ if (std.mem.eql(u8, row.path orelse continue, pfx)) return p.runBuiltin(row.cmd, p.active, "", null);
}
- for (leader_rows) |row| {
- if (std.mem.startsWith(u8, row.path, pfx)) {
+ for (builtin_rows) |row| {
+ if (std.mem.startsWith(u8, row.path orelse continue, pfx)) {
p.leader_on = true;
return;
}