summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig386
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