diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-31 01:18:53 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:08 -0300 |
| commit | 8a7112f08e0ce93a37b177e3aa83637e1691b451 (patch) | |
| tree | 4a5fe4b0da18cf98cd91950a218ad4a6d324b0cc /src/pardes.zig | |
| parent | a77e342eb7fa4add700a96a661226649e8c4763f (diff) | |
| download | pardes-8a7112f08e0ce93a37b177e3aa83637e1691b451.tar.gz pardes-8a7112f08e0ce93a37b177e3aa83637e1691b451.zip | |
Look and Exec are builtins like any other
Their only special feature is now the keys they are assigned. `Look main.zig`
typed in a tag is the same look a right click is; config.look_cmd/exec_cmd
point the two buttons and Enter/Tab at them, so retargeting Enter to Grep is
one line. actOnSelection — a hand-written cascade with the builtin dispatch
nested inside it — is gone, and the `button` parameter it threaded through five
KEYBOARD call sites went with it.
New syntax, spelled once in config.zig: @`ls -la` names a command to run
rather than a file to open. Word expansion takes the quoted run whole, the way
acme does for its own </|/> words, so a click inside one does not hand Look the
fragment `ls`.
And it nests: @`Look .` unwraps, re-enters the dispatcher, and looks at the
directory. Guarded at depth 8, which is unreachable today (every re-entry
strips a word or a delimiter pair, so the string strictly shrinks) and exists
for a future syntax that does not shrink.
leader_path is now optional: a builtin may have no SPC path when its shortcut
is a key and a mouse button. New golden cmdword; the other 59 unmoved.
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 386 |
1 files changed, 219 insertions, 167 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 01026347..2365c33f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -51,8 +51,9 @@ const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; // Builtins: executing the name (middle-click / Tab) runs it through the ONE -// dispatcher (runBuiltin, reached from actOnSelection), no matter where the -// name appears. One STRUCT per builtin in builtins.zig — name, comment and +// dispatcher (runBuiltin, reached from execute), no matter where the name +// appears — and Look and Exec are two of them, so the click itself is a +// builtin. One STRUCT per builtin in builtins.zig — name, comment and // body in one place — and this enum is folded out of THAT FILE'S declarations // at comptime, so the enum FIELD NAME is still the user-visible word (the one // in the topbar, the one sitting in a tag, the one Help prints, the one you @@ -69,26 +70,38 @@ const Builtin = builtins.Builtin(); /// 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 }; -pub const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: { +/// 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: [@typeInfo(Builtin).@"enum".fields.len]Leader = undefined; + var rows: [leader_n]Leader = undefined; // the widest spaced-out path ("h t" = 3), so the names line up var keyw: usize = 0; - for (std.enums.values(Builtin)) |b| keyw = @max(keyw, 2 * config.leader_path.get(b).len - 1); - for (std.enums.values(Builtin), 0..) |b, i| { - const path = config.leader_path.get(b); + for (std.enums.values(Builtin)) |b| { + const p = config.leader_path.get(b) orelse continue; + keyw = @max(keyw, 2 * p.len - 1); + } + var n: usize = 0; + for (std.enums.values(Builtin)) |b| { + const path = config.leader_path.get(b) orelse continue; var keys: []const u8 = ""; for (path, 0..) |c, k| { if (k > 0) keys = keys ++ " "; keys = keys ++ &[_]u8{c}; } - rows[i] = .{ + rows[n] = .{ .path = path, .cmd = b, .line = "SPC " ++ keys ++ (" " ** (keyw - keys.len)) ++ " " ++ @tagName(b), }; + n += 1; } // insertion sort by path: a group sorts right before what extends it for (1..rows.len) |i| { @@ -921,6 +934,8 @@ pub const Pardes = struct { /// middle+left chord argument: a kept left selection captured by a left /// press during an active middle select-drag; consumed on middle release chord_arg: ?[]u8 = null, + /// how many `execute` calls are on the stack — see max_exec_depth + exec_depth: u8 = 0, /// The one language query in flight. ONE, deliberately: every one of these /// is a keystroke the user is waiting on, so a second press means "I meant @@ -1282,12 +1297,8 @@ pub const Pardes = struct { const hi = @min(b.hi + 1, text.len); return if (hi > b.lo) text[b.lo..hi] else null; } - const col: usize = @min(@as(usize, pane.tag_col), text.len); - var lo = col; - while (lo > 0 and config.isFileChar(text[lo - 1])) lo -= 1; - var hi = col; - while (hi < text.len and config.isFileChar(text[hi])) hi += 1; - return if (hi > lo) text[lo..hi] else null; + const b = config.wordBounds(text, @min(@as(usize, pane.tag_col), text.len)); + return if (b.hi > b.lo) text[b.lo..b.hi] else null; } /// tag-edit key dispatch: a modal one-line editor over the rendered tag, @@ -1302,8 +1313,7 @@ pub const Pardes = struct { if (hit(key, config.look_key) or hit(key, config.exec_key)) { if (p.tagChordText(pane)) |txt| { pane.tag_sel = false; - const btn: Mouse.Button = if (hit(key, config.look_key)) config.look_button else config.exec_button; - p.actOnSelection(btn, p.active, txt, null); + p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", txt); } return; } @@ -1364,11 +1374,11 @@ pub const Pardes = struct { // `:w<Tab>` by default. // Nothing under the cursor means nothing ran: the tag keeps focus. if (hit(key, config.look_key) or hit(key, config.exec_key)) { - const btn: Mouse.Button = if (hit(key, config.look_key)) config.look_button else config.exec_button; + const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; const txt = p.tagChordText(pane) orelse return; const id = p.active; exitTagEdit(pane); // leave the tag FIRST: Del frees the pane - p.actOnSelection(btn, id, txt, null); + p.runBuiltin(cmd, id, "", txt); return; } // y — yank what the chord would run: the selection, else the word under @@ -1494,7 +1504,7 @@ pub const Pardes = struct { if (hit(key, config.look_key) or hit(key, config.exec_key)) { const word = wordAtCol(bar, @intCast(cur)); p.topbar_col = null; - if (word.len > 0) p.actOnSelection(config.exec_button, p.active, word, null); + if (word.len > 0) _ = p.execute(p.active, word); return; } // j — back down onto a tagline, the mirror of the k that got you here: @@ -1562,7 +1572,8 @@ pub const Pardes = struct { return out.items; } - /// acme: a no-drag middle/right click expands to the file-ish word under it + /// acme: a no-drag middle/right click expands to the word under it — + /// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling) fn expandSel(p: *Pardes, pane: *Pane, sl: *Sel) void { if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return; const text = p.paneText(pane) catch return; @@ -1572,13 +1583,10 @@ pub const Pardes = struct { if (v != sl.r0) continue; const col: usize = @intCast(@max(0, sl.c0)); if (col > line.len) return; - var hi = col; - while (hi < line.len and config.isFileChar(line[hi])) hi += 1; - var lo = col; - while (lo > 0 and config.isFileChar(line[lo - 1])) lo -= 1; - if (hi > lo) { - sl.c0 = @intCast(lo); - sl.c1 = @intCast(hi - 1); + const b = config.wordBounds(line, col); + if (b.hi > b.lo) { + sl.c0 = @intCast(b.lo); + sl.c1 = @intCast(b.hi - 1); } return; } @@ -1625,6 +1633,21 @@ pub const Pardes = struct { return null; } + /// Splice a chord argument onto what the gesture pointed at. acme's 2-1 + /// chord means "run this WITH that", and that is a command LINE: `Grep` + /// plus a held `foo` is the same string `Grep foo` you could have typed, + /// so it goes down the one path that already knows how to split a name + /// from its tail. The alternative — a second argument threaded past the + /// dispatcher — is what used to be here, and it could not survive Exec + /// becoming an ordinary builtin with one argument slot like every other. + /// Scratch-owned; `txt` itself when there is nothing to splice. + fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 { + const a = std.mem.trim(u8, arg orelse return txt, " \t\r\n"); + if (a.len == 0) return txt; + const t = std.mem.trim(u8, txt, " \t\r\n"); + return std.fmt.allocPrint(p.scratch.allocator(), "{s} {s}", .{ t, a }) catch txt; + } + fn handleKey(p: *Pardes, key: Key) void { // the topbar holds the keyboard (`k` off the topmost tagline): row 0 is // its own one-line normal mode and owns every key until Esc or a chord. @@ -1712,7 +1735,7 @@ pub const Pardes = struct { // (v/x/X, terminal n/N, search n/N); implicit motion residue falls back // to the file-ish word under the cursor. if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { - const btn: Mouse.Button = if (hit(key, config.look_key)) config.look_button else config.exec_button; + const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; pane.pinCursor(); const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { @@ -1720,14 +1743,14 @@ pub const Pardes = struct { pane.vsel.active = false; pane.msel.active = false; pane.select = false; - p.actOnSelection(btn, p.active, txt, null); + p.runBuiltin(cmd, p.active, "", txt); return; } } var sel = cursorWordSel(pane); p.expandSel(pane, &sel); const word = p.selectionText(pane, sel) catch return; - p.actOnSelection(btn, p.active, word, null); + p.runBuiltin(cmd, p.active, "", word); return; } // normal-mode `p`/`P` paste the core's yank register directly (helix: @@ -2780,7 +2803,7 @@ pub const Pardes = struct { const ln = modal.lineSlice(rf.content, @intCast(step)); var hi: usize = 0; while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; - p.actOnSelection(config.look_button, rid, ln[0..hi], null); + p.lookAt(rid, ln[0..hi]); // the look may focus what it opened — a Find row opens a whole new // file pane every time — so focus comes back to the pane that owns the // search and the next n keeps stepping. A `/` row looks at the @@ -2846,7 +2869,7 @@ pub const Pardes = struct { // dedup-onto-an-open-pane and centering are the ONE look path. // (helix would also push its jumplist here; pardes has none, so // there is nothing to push — do not read this as one.) - return p.actOnSelection(config.look_button, w.pane, ln[0..hi], null); + return p.lookAt(w.pane, ln[0..hi]); } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); @@ -4286,7 +4309,7 @@ pub const Pardes = struct { // drag to chord into, so the argument is simply // whatever is selected right now: select a word, // then click Grep, and it greps that word. - if (word.len > 0) p.actOnSelection(config.exec_button, p.active, word, p.heldSelection(p.active)); + if (word.len > 0) _ = p.execute(p.active, p.withArg(word, p.heldSelection(p.active))); } return; } @@ -4444,14 +4467,15 @@ pub const Pardes = struct { } } else { // acme execute (middle) / look (right): a no-drag click - // expands to the file-ish word first; a captured chord + // expands to the word under it first; a captured chord // argument rides along and is consumed here. p.expandSel(pane, &pane.sel[b]); const txt = p.selectionText(pane, pane.sel[b]) catch null; const arg = p.chord_arg; p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); - if (txt) |tx| p.actOnSelection(s.button, s.id, tx, arg); + const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; + if (txt) |tx| p.runBuiltin(cmd, s.id, "", p.withArg(tx, arg)); } }, .tag => {}, // dragUpdate already left the tag cursor + selection set @@ -4904,158 +4928,186 @@ pub const Pardes = struct { return false; } - fn actOnSelection(p: *Pardes, button: Mouse.Button, id: usize, txt: []const u8, arg: ?[]const u8) void { + /// LOOK — the Look builtin's body, and so what a right click, an Enter and + /// the word `Look` all end at. Resolve `txt` against the panes' directories + /// and open (or focus) whatever it names; a word that names nothing is a + /// search of the pane it came from, which is acme's button-3. + pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void { const pane = p.panes[id] orelse return; - if (button == config.look_button) { - var realbuf: [4096]u8 = undefined; - // an already-loaded pane wins BEFORE any filesystem resolve: the - // web build has no fs (a look would otherwise be inert even for - // panes sitting in the session), and native gets the same dedup - // it always did, just without touching disk. Pane paths are - // canonical (realpath'd or dump-given), so match the word as-is - // here and joined onto each directory below. - const pl = look.parsePathLine(std.mem.trim(u8, txt, " \t\r\n")); - if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.line, pl.col)) return; - // The word is resolved against the pane DIRECTORIES in access - // order: the pane the click came from FIRST — its answer is the - // one taken, so nothing that resolves today moves — then every - // other live pane, most recently focused first (focus_hist runs - // least-recent -> active, so it is that array backwards; the - // clicked pane is not always `active`, a right click does not - // focus). Only when ALL of them fail does the word fall through to - // the search below: a name you can read in one window is openable - // from any of them. Each attempt is a realpath + an open and - // shells share cwds constantly, so a seen-list holds every - // directory to one try; an absolute word — and `@pN`, which reads - // no directory at all — answers the same everywhere and stops - // after the first pass. - var found: look.Target = .none; - var seen: [MAX_PANES][]const u8 = undefined; - var nseen: usize = 0; - var n: usize = 0; - cand: while (n <= p.nfocus) : (n += 1) { - const k = if (n == 0) id else p.focus_hist[p.nfocus - n]; - if (n > 0 and k == id) continue; // tried first, skip the rerun - const op = p.panes[k] orelse continue; - const dir = if (op.file) |f| (std.fs.path.dirname(f.path) orelse "/") else op.cwdSlice(); - for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand; - seen[nseen] = dir; - nseen += 1; - if (pl.path.len > 0 and pl.path[0] != '/') { - var joinbuf: [2048]u8 = undefined; - if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j| - if (p.focusPaneByPath(j, pl.line, pl.col)) return; - } - found = look.resolve(txt, dir, &realbuf); - if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; - } - switch (found) { - // acme button-3: a word that names no file/dir is a search of - // the pane it was clicked in — exactly what `/` runs (n/N then - // walk the results). Paths (src/a/b.rs:100) still resolve above - // and open; only the non-file case falls through here. A shell - // searches its scrollback like anything else, EXCEPT in tty - // mode, where the click belongs to the program on the other - // end; an image pane has no text to search either way. - .none => { - const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; - if (pane.image == null and bmode != .tty) p.runSearch(id, txt, .text); - }, - // `@p7:10:5`: pane 7, line 10, column 5 — how a search result - // points at a terminal or an output buffer, neither of which - // has a path. - .pane => |t| p.focusPaneLine(t.id, t.line, t.col), - .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), - .dir => |dir| { - // focus an existing terminal on this dir, else fork one below - for (p.panes, 0..) |slot, i| { - if (slot) |tt| if (tt.isTerminal() and std.mem.eql(u8, tt.cwdSlice(), dir)) { - p.active = i; - p.emitWrite(i, "ls\r"); - return; - }; - } - const free = p.freeSlot() orelse return; - const nt = p.newShell(free, dir) catch return; - nt.greet = true; - const src = p.splitParent(id); - const f = p.layoutFindTerm(src).?; - p.layoutInsert(f.col, f.idx + 1, free); - p.splitBelow(src, nt); - p.active = free; - }, - .file => |target| { - // focus an existing pane on this path (rescrolled), else open - if (p.focusPaneByPath(target.path, target.line, target.col)) return; - const free = p.freeSlot() orelse return; - const nt = file_pane.open(p, free, target.path, target.line) catch return; - if (target.col > 0) nt.cur_col = @intCast(target.col - 1); - p.placeDoc(id, free, nt); - // center the target line: the pane's real body height only - // exists after placement, so lay out now and pull the - // scroll up by half a body (line 0 opens stay at the top) - p.computeGeom(); - nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; - }, - .image => |target| { - if (p.focusPaneByPath(target.path, 0, 0)) return; - const free = p.freeSlot() orelse return; - // petscii by default when the host has no kitty graphics - const nt = p.openImageView(free, target.path, !p.kitty_ok) catch return; - p.placeDoc(id, free, nt); - }, - } + const trimmed = std.mem.trim(u8, txt, " \t\r\n"); + // `` @`ls -la` `` names a COMMAND, not a path: run it, and land in the + // pane that answers — looking at a thing means being SHOWN it, and a + // command's output is what there is to be shown. Looking at a + // DIRECTORY has always been exactly this (below: focus a shell there + // and make it `ls`); this is that rule spelled generally. + if (config.commandWord(trimmed)) |cmd| { + if (p.execute(id, cmd)) |dst| p.active = dst; return; } - // execute (the execute button / the execute key): a builtin's name - // runs the builtin, anything else is a command line for a shell. - if (button != config.exec_button) return; - const cmd = std.mem.trim(u8, txt, " \t\r\n"); - if (cmd.len == 0) return; - // three builtins take an ARGUMENT — `Restore <path>`, `Find <pat>`, - // `Grep <pat>` — so their name matches with a tail; every other name - // must match whole, so `Kill foo` is a shell command, not Kill. + var realbuf: [4096]u8 = undefined; + // an already-loaded pane wins BEFORE any filesystem resolve: the + // web build has no fs (a look would otherwise be inert even for + // panes sitting in the session), and native gets the same dedup + // it always did, just without touching disk. Pane paths are + // canonical (realpath'd or dump-given), so match the word as-is + // here and joined onto each directory below. + const pl = look.parsePathLine(trimmed); + if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.line, pl.col)) return; + // The word is resolved against the pane DIRECTORIES in access + // order: the pane the click came from FIRST — its answer is the + // one taken, so nothing that resolves today moves — then every + // other live pane, most recently focused first (focus_hist runs + // least-recent -> active, so it is that array backwards; the + // clicked pane is not always `active`, a right click does not + // focus). Only when ALL of them fail does the word fall through to + // the search below: a name you can read in one window is openable + // from any of them. Each attempt is a realpath + an open and + // shells share cwds constantly, so a seen-list holds every + // directory to one try; an absolute word — and `@pN`, which reads + // no directory at all — answers the same everywhere and stops + // after the first pass. + var found: look.Target = .none; + var seen: [MAX_PANES][]const u8 = undefined; + var nseen: usize = 0; + var n: usize = 0; + cand: while (n <= p.nfocus) : (n += 1) { + const k = if (n == 0) id else p.focus_hist[p.nfocus - n]; + if (n > 0 and k == id) continue; // tried first, skip the rerun + const op = p.panes[k] orelse continue; + const dir = if (op.file) |f| (std.fs.path.dirname(f.path) orelse "/") else op.cwdSlice(); + for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand; + seen[nseen] = dir; + nseen += 1; + if (pl.path.len > 0 and pl.path[0] != '/') { + var joinbuf: [2048]u8 = undefined; + if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j| + if (p.focusPaneByPath(j, pl.line, pl.col)) return; + } + found = look.resolve(txt, dir, &realbuf); + if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; + } + switch (found) { + // acme button-3: a word that names no file/dir is a search of + // the pane it was clicked in — exactly what `/` runs (n/N then + // walk the results). Paths (src/a/b.rs:100) still resolve above + // and open; only the non-file case falls through here. A shell + // searches its scrollback like anything else, EXCEPT in tty + // mode, where the click belongs to the program on the other + // end; an image pane has no text to search either way. + .none => { + const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; + if (pane.image == null and bmode != .tty) p.runSearch(id, txt, .text); + }, + // `@p7:10:5`: pane 7, line 10, column 5 — how a search result + // points at a terminal or an output buffer, neither of which + // has a path. + .pane => |t| p.focusPaneLine(t.id, t.line, t.col), + .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), + .dir => |dir| { + // focus an existing terminal on this dir, else fork one below + for (p.panes, 0..) |slot, i| { + if (slot) |tt| if (tt.isTerminal() and std.mem.eql(u8, tt.cwdSlice(), dir)) { + p.active = i; + p.emitWrite(i, "ls\r"); + return; + }; + } + const free = p.freeSlot() orelse return; + const nt = p.newShell(free, dir) catch return; + nt.greet = true; + const src = p.splitParent(id); + const f = p.layoutFindTerm(src).?; + p.layoutInsert(f.col, f.idx + 1, free); + p.splitBelow(src, nt); + p.active = free; + }, + .file => |target| { + // focus an existing pane on this path (rescrolled), else open + if (p.focusPaneByPath(target.path, target.line, target.col)) return; + const free = p.freeSlot() orelse return; + const nt = file_pane.open(p, free, target.path, target.line) catch return; + if (target.col > 0) nt.cur_col = @intCast(target.col - 1); + p.placeDoc(id, free, nt); + // center the target line: the pane's real body height only + // exists after placement, so lay out now and pull the + // scroll up by half a body (line 0 opens stay at the top) + p.computeGeom(); + nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; + }, + .image => |target| { + if (p.focusPaneByPath(target.path, 0, 0)) return; + const free = p.freeSlot() orelse return; + // petscii by default when the host has no kitty graphics + const nt = p.openImageView(free, target.path, !p.kitty_ok) catch return; + p.placeDoc(id, free, nt); + }, + } + } + + /// how deep `execute` may re-enter itself. Nothing can reach this today: + /// every door back in strips at least one word (`Exec X` -> `X`) or one + /// pair of delimiters (`` @`X` `` -> `X`), so the command line strictly + /// shrinks and a cycle cannot close — executing the bare word `Exec` runs + /// out of argument immediately. The counter is here so that a syntax added + /// later which does NOT shrink (an alias, a macro) stops instead of hanging + /// the editor, and the ceiling is small because a human nesting eight deep + /// has made a different mistake. + const max_exec_depth = 8; + + /// EXECUTE — the Exec builtin's body, and so what a middle click, a Tab + /// and the word `Exec` all end at. A builtin's NAME runs the builtin; + /// anything else is a command line typed at a shell. Returns the pane it + /// was typed into, which is what Look focuses and an execute deliberately + /// does not. + pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { + const pane = p.panes[id] orelse return null; + var cmd = std.mem.trim(u8, txt, " \t\r\n"); + // `` @`ls -la` `` is the same command line wearing the syntax that + // makes it clickable inside ordinary prose: unwrap and carry on. The + // loop (rather than one peel) costs nothing and is honest about the + // form nesting. + while (config.commandWord(cmd)) |inner| cmd = std.mem.trim(u8, inner, " \t\r\n"); + if (cmd.len == 0) return null; + if (p.exec_depth >= max_exec_depth) return null; + p.exec_depth += 1; + defer p.exec_depth -= 1; + // The builtins that take an ARGUMENT match their name with a TAIL: + // `Restore <path>`, `Find <pat>`, `Grep <pat>`, `Rename <name>`, + // `WsSymbols <sym>`, and the two verbs themselves — `Look <word>`, + // `Exec <cmd>`, which is what makes `` @`Look .` `` nest (the tail goes + // straight back through here). Every other name must match WHOLE, so + // `Kill foo` is a shell command and not Kill. const sp = std.mem.indexOfAny(u8, cmd, " \t"); const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: { const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null; break :blk switch (head) { - .Restore, .Find, .Grep => head, + .Restore, .Find, .Grep, .Rename, .WsSymbols, .Look, .Exec => head, else => null, }; }; if (bi) |b| { - // the executed text's own argument if it has one, else the chord's - // kept selection: `Grep foo` and select-foo-then-click-Grep are the - // same command reached two ways const typed = if (sp) |s| std.mem.trim(u8, cmd[s + 1 ..], " \t") else ""; - return p.runBuiltin(b, id, cmd, if (typed.len > 0) typed else arg); + p.runBuiltin(b, id, cmd, if (typed.len > 0) typed else null); + return null; } // terminal: run in itself. file pane: run in a shell in the file's // directory (found by cwd, else freshly forked). - const dst: ?usize = if (pane.file) |f| + const dst = (if (pane.file) |f| p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") else - id; - if (dst) |di| { - term_pane.padOutputBelowEdits(p, di); - p.emitWrite(di, cmd); - // a chord argument (kept left selection) rides as a trailing CLI - // argument - if (arg) |a| { - const at = std.mem.trim(u8, a, " \t\r\n"); - if (at.len > 0) { - p.emitWrite(di, " "); - p.emitWrite(di, at); - } - } - p.emitWrite(di, "\r"); - } + id) orelse return null; + term_pane.padOutputBelowEdits(p, dst); + p.emitWrite(dst, cmd); + p.emitWrite(dst, "\r"); + return dst; } /// Run a builtin on pane `id`. `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 + /// its path back out of it) and `arg` the builtin's ARGUMENT — the tail + /// after the name, which is why Grep and Find run straight away when there + /// is one instead of asking, and which for Look and Exec is the whole + /// operand. A gesture that points at a word (a click, an Enter) passes it + /// as `arg` with no `txt`: it named no builtin, config.look_cmd did. The /// leader passes "" and null: a key path names a builtin, never an /// argument. The topbar builtins are global; the pane-scoped ones (Save, /// Del, Delcol, the image toggles, and the window group, which moves focus |
