summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig34
-rw-r--r--src/config.zig62
-rw-r--r--src/pardes.zig386
3 files changed, 309 insertions, 173 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index 500ce264..cf144d33 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -106,6 +106,36 @@ pub fn Builtin() type {
return @Enum(Tag, .exhaustive, &names, &std.simd.iota(Tag, cmds.len));
}
+// ---- the two acme verbs ----
+
+// Look and Execute are the verbs the whole environment is built on, and they
+// are BUILTINS: `Look main.zig` typed in a tag and executed is the same look a
+// right click on `main.zig` is, `Exec ls` the same as a middle click on `ls`.
+// The mouse buttons and Enter/Tab are not a second path into them any more —
+// they are two bindings pointing here (config.look_cmd / exec_cmd), the status
+// `SPC f s` has relative to Save. That is the whole feature: what used to be a
+// `button` parameter threaded through every keyboard call site, with the
+// builtin dispatch nested INSIDE it, is now one word each.
+//
+// The operand is `arg` in both — a name's tail (`Look main.zig`), else the
+// selection a chord kept, else the word the gesture pointed at, which the
+// gesture resolves and passes. Nothing to act on means nothing happens, the
+// way `Save` on a terminal is inert.
+
+pub const Look = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lookAt(c.id, c.arg orelse return);
+ }
+};
+
+pub const Exec = struct {
+ pub fn run(c: Ctx) void {
+ // the destination pane is Look's business (it focuses what answered);
+ // an execute deliberately leaves you where you were
+ _ = c.p.execute(c.id, c.arg orelse return);
+ }
+};
+
// ---- session ----
pub const Kill = struct {
@@ -159,8 +189,8 @@ pub const Crt = struct {
};
// 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
+// printed and the execute dispatcher matched by hand; as builtins they are
+// executable anywhere, pressable under SPC and listed by `SPC ?`, 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
diff --git a/src/config.zig b/src/config.zig
index 65ac397b..394f9ca3 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -59,10 +59,16 @@ pub const leader: []const Chord = &.{.{ .cp = ' ' }};
pub const leader_help: u8 = '?';
/// SPC leader: ONE key path per builtin, the whole remapping surface. An
-/// EnumArray, so a new enum field is a compile error until it has a path.
-/// Groups are just shared first letters (f files, h docs, c columns, t
-/// toggles, s session, l language, w windows).
-pub const leader_path = std.EnumArray(Builtin, []const u8).init(.{
+/// EnumArray, so a new enum field is a compile error until someone has DECIDED
+/// its path. Groups are just shared first letters (f files, h docs, c columns,
+/// t toggles, s session, l language, w windows).
+///
+/// `null` = this builtin's shortcut is not a leader path. Look and Exec are
+/// the two: their shortcuts are Enter/Tab and the two mouse buttons below, and
+/// a third spelling under SPC would be a key that does nothing you cannot
+/// already do with the key your hand is on. The option is the honest type —
+/// "every builtin has a leader path" was only ever true by accident.
+pub const leader_path = std.EnumArray(Builtin, ?[]const u8).init(.{
.Help = &[_]u8{leader_help},
// The whole LANGUAGE group lives under `l`, and pardes's own builtins keep
// the letters they always had — `SPC d` is Del, `SPC k` is Kill.
@@ -117,6 +123,9 @@ pub const leader_path = std.EnumArray(Builtin, []const u8).init(.{
.Up = "wk",
.Right = "wl",
.Toggleterm = "wt",
+ // the two acme verbs: keys and buttons, no leader path — see above
+ .Look = null,
+ .Exec = null,
});
// ---- the acme chords ----
@@ -134,6 +143,15 @@ pub const exec_button: Mouse.Button = .middle;
/// sweep, focus, place the cursor, and the left half of every acme chord
pub const select_button: Mouse.Button = .left;
+/// ...and WHAT those four run. Look and Exec are ORDINARY builtins — the same
+/// kind of thing Save and Grep are, executable by name wherever text lives
+/// (`Look main.zig` in a tag does what a right click on `main.zig` does) — so
+/// the four bindings above are bindings like any other, and these two lines
+/// are the whole of what makes them special. Point `look_cmd` at `.Grep` and
+/// Enter greps.
+pub const look_cmd: Builtin = .Look;
+pub const exec_cmd: Builtin = .Exec;
+
// ---- windows ----
/// helix's window prefix. It stays despite `SPC w` covering the same four
@@ -278,6 +296,42 @@ pub fn isFileChar(c: u8) bool {
};
}
+/// `` @`ls -la` `` — a word that names a COMMAND to run rather than a file to
+/// open. Both halves are named here because they are a CHOICE: the `@` marks
+/// it as ours (it is already a file char, so it can never split a path) and
+/// the backquotes hold a command line with spaces in it, which is the whole
+/// point — a file-ish word cannot. Respell them here and nowhere else.
+pub const cmd_open = "@`";
+pub const cmd_close: u8 = '`';
+
+/// The command inside `` @`...` ``, or null when `w` is not one.
+pub fn commandWord(w: []const u8) ?[]const u8 {
+ if (w.len <= cmd_open.len or !std.mem.startsWith(u8, w, cmd_open)) return null;
+ if (w[w.len - 1] != cmd_close) return null;
+ return w[cmd_open.len .. w.len - 1];
+}
+
+/// The bounds of the word at `col` in `line` — THE expansion a no-drag
+/// look/execute click and the tag chord both use.
+///
+/// A `` @`...` `` run is taken WHOLE and wins outright: a backtick is not a
+/// file char, so the plain scan below would stop dead inside one and hand a
+/// look the fragment `ls` out of `` @`ls -la` ``. Acme does exactly this for
+/// its own `<`/`|`/`>` command words. Otherwise it is the file-ish word.
+pub fn wordBounds(line: []const u8, col: usize) struct { lo: usize, hi: usize } {
+ var i: usize = 0;
+ while (std.mem.indexOfPos(u8, line, i, cmd_open)) |o| {
+ const close = std.mem.indexOfScalarPos(u8, line, o + cmd_open.len, cmd_close) orelse break;
+ if (col >= o and col <= close) return .{ .lo = o, .hi = close + 1 };
+ i = close + 1;
+ }
+ var lo = col;
+ while (lo > 0 and isFileChar(line[lo - 1])) lo -= 1;
+ var hi = col;
+ while (hi < line.len and isFileChar(line[hi])) hi += 1;
+ return .{ .lo = lo, .hi = hi };
+}
+
/// separates a path from its LINE and COL: `main.zig:100:7`. Must be a member
/// of isFileChar or the suffix would not be part of the word in the first
/// place.
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