From 7022d738dba00049e820e969cabc34ecbeaea465 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 31 Jul 2026 02:40:11 -0300 Subject: Help lists every builtin and every way to reach it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/pardes.zig | 159 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 120 insertions(+), 39 deletions(-) (limited to 'src/pardes.zig') 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; } -- cgit v1.3