summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/lsp.md2
-rw-r--r--next-steps.txt2
-rw-r--r--src/builtins.zig34
-rw-r--r--src/config.zig62
-rw-r--r--src/pardes.zig386
-rw-r--r--test/snapshots/cmdword.golden217
-rw-r--r--test/snapshots/cmdword.snap78
7 files changed, 606 insertions, 175 deletions
diff --git a/docs/lsp.md b/docs/lsp.md
index 91d07231..a22a4c52 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -57,7 +57,7 @@ Every backend renders into one format:
1-based line and column, absolute path. This is the format `look.zig` already
resolves, `runSearch` already produces and `n`/`N` already step — so:
-- **one row from a goto** → jump straight there (`actOnSelection`)
+- **one row from a goto** → jump straight there (`lookAt`)
- **several rows** → an output buffer, which `n`/`N` walk
which means helix's multi-result picker required **no picker code at all**. The
diff --git a/next-steps.txt b/next-steps.txt
index cd745f17..29073998 100644
--- a/next-steps.txt
+++ b/next-steps.txt
@@ -8,6 +8,6 @@
- Use zig's 0.16 new threaded io and the std functions that the build system uses to listen for file changes, it needs to use something from the os so it's not a busy wait. It will only track files on open panes. when a file is updated, doing Undo will return to the state that it was before so if a file automatically updates the user won't lose the unsaved state as it was before and the editor won't need to handle with merging the unsaved changes and the new update.
- There's some bug when rendering from a ssh session - the screen gets dirty with old chars when scrolling.
- There's some bad performance when dealing with specially large files or long lines; write a perf harness for this and optimize the performance.
-- The Look and Exec should be regular semantic entities, i.e. their only special feature should be the keys/shortcuts they're assigned so we could execute Look main.zig and it would call the regular look on it, but also we could Look some string like @`ls -la` (you can change the syntax, but this also depends on that syntax change so it's centralized and easy to change later). This basically will allow you to make some big refactorings and cut a lot of code (hopefully) and also allow some funny things like @`Look .` (basically allowing nested things like this for no good reason other than, it's elegant and it's fun!)
++ The Look and Exec should be regular semantic entities, i.e. their only special feature should be the keys/shortcuts they're assigned so we could execute Look main.zig and it would call the regular look on it, but also we could Look some string like @`ls -la` (you can change the syntax, but this also depends on that syntax change so it's centralized and easy to change later). This basically will allow you to make some big refactorings and cut a lot of code (hopefully) and also allow some funny things like @`Look .` (basically allowing nested things like this for no good reason other than, it's elegant and it's fun!)
- We'll make the focus history a meaningful and strong semantic entity: instead of being a stack of Panes, it'll be a stack of locations. This will allow us to implement vim's ctrl-i and ctrl-o that will just move the current focus up or down on that stack. there'll be also a new builtin that will toggle the focus of the latest panes, basically moving the current focus by changing the current pointer to the location on the stack. The Jumplist (new builtin) will be an Output pane with each line being a location that can be Looked, n/N should work.
+ The main pardes.zig file should hold the logic that applies to all kinds of panes and backends. Maybe right now the pane specifiticies are too intermingled. Maintain the panes logics into their own files, right now tty.zig exists but I don't think output panes live by themselves nor file panes on their own files. Also, each builtin command should be implemented as a struct, the current logic on switching on the enum should use some comptime logic to build the tagged union/enum from those structs now. Also I think it's time to create a few directories on @src now since some files are specific to some backends and others are part of the core, it should be clear just by looking at the directory structure (don't add more dir layers, just new dirs under src).
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
diff --git a/test/snapshots/cmdword.golden b/test/snapshots/cmdword.golden
new file mode 100644
index 00000000..03e272be
--- /dev/null
+++ b/test/snapshots/cmdword.golden
@@ -0,0 +1,217 @@
+== snap opened grid=120x30 cursor=7,2
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/cmdword/cwd/cmds.txt Save Del NOR /tmp/pardes-snap/cmdword/cwd Del
+| 1 @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
+| cmds.txt p.txt zzmark.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap atexec grid=120x30 cursor=7,2
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/cmdword/cwd/cmds.txt Save Del NOR /tmp/pardes-snap/cmdword/cwd Del
+| 1 @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
+| cmds.txt p.txt zzmark.txt
+|
+| ZZE
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap atlook grid=120x30 cursor=64,8
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/cmdword/cwd/cmds.txt Save Del NOR /tmp/pardes-snap/cmdword/cwd Del
+| 1 @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
+| cmds.txt p.txt zzmark.txt
+|
+| ZZE
+|
+| ZZL
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap lookword grid=120x30 cursor=7,17
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/cmdword/cwd/cmds.txt Save Del NOR /tmp/pardes-snap/cmdword/cwd Del
+| 1 @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
+| cmds.txt p.txt zzmark.txt
+|
+| ZZE
+|
+| ZZL
+|
+|
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/cmdword/cwd/p.txt Save Del
+| 1 just text
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap nested grid=120x30 cursor=64,10
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/cmdword/cwd/cmds.txt Save Del NOR /tmp/pardes-snap/cmdword/cwd Del
+| 1 @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
+| cmds.txt p.txt zzmark.txt
+|
+| ZZE
+|
+| ZZL
+|
+| cmds.txt p.txt zzmark.txt
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/cmdword/cwd/p.txt Save Del
+| 1 just text
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap execword grid=120x30 cursor=102,1
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/cmdword/cwd/cmds.txt Save Del INS /tmp/pardes-snap/cmdword/cwd Del Exec
+| 1 @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
+| cmds.txt p.txt zzmark.txt
+|
+| ZZE
+|
+| ZZL
+|
+| cmds.txt p.txt zzmark.txt
+|
+|
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/cmdword/cwd/p.txt Save Del
+| 1 just text
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tagcmd grid=120x30 cursor=116,1
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/cmdword/cwd/cmds.txt Save Del INS /tmp/pardes-snap/cmdword/cwd Del Exec @`echo ZZ''T`
+| 1 @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
+| cmds.txt p.txt zzmark.txt
+|
+| ZZE
+|
+| ZZL
+|
+| cmds.txt p.txt zzmark.txt
+|
+| ZZT
+|
+|
+|
+|
+| NOR /tmp/pardes-snap/cmdword/cwd/p.txt Save Del
+| 1 just text
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/cmdword.snap b/test/snapshots/cmdword.snap
new file mode 100644
index 00000000..f4d4fed4
--- /dev/null
+++ b/test/snapshots/cmdword.snap
@@ -0,0 +1,78 @@
+# `@`...`` — a word that names a COMMAND instead of a file to open. The
+# delimiters are config.cmd_open/cmd_close and nothing else in the editor knows
+# how one is spelled.
+#
+# A backtick is not a file char, so the ordinary word expansion would stop dead
+# inside one and hand a look the fragment `echo`. config.wordBounds takes the
+# whole run instead, which is what makes a single CLICK the way you invoke one
+# (acme does the same for its own `<`/`|`/`>` command words).
+#
+# EXECUTE runs it and leaves you where you were; LOOK runs it and lands you in
+# the shell that answered — looking at a thing means being shown it, and a
+# directory's look has always been exactly that (a shell there, ls'd).
+#
+# And Look and Exec are ORDINARY BUILTINS now, so their names are executable
+# words like Save or Grep: `Look p.txt` run off the page is the same look a
+# right click on `p.txt` is. Which is also what makes `@`Look .`` nest — Exec
+# unwraps it and the contents go straight back through the one dispatcher.
+file cmds.txt @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
+file p.txt just text
+file zzmark.txt x
+start 30 120 cmds.txt
+wait 8000 Kill Newcol
+wait 8000 cmds.txt
+stable 700 20000
+snap opened
+# a single middle CLICK inside the run (col 11 is inside `echo`) expands to the
+# whole `@`echo ZZ''E`` and executes it in a shell; focus stays on the file
+press middle 11 3
+release middle 11 3
+wait 10000 ZZE
+stable 700 15000
+snap atexec
+# LOOK on the second one (col 24, inside its `echo`) runs it too AND focuses
+# the shell that answered — that is the whole difference between the two verbs
+press right 24 3
+release right 24 3
+wait 10000 ZZL
+stable 700 15000
+snap atlook
+# `Look p.txt` middle-dragged: the builtin by NAME, with an argument
+press middle 36 3
+drag middle 45 3
+release middle 45 3
+wait 10000 just text
+stable 700 15000
+snap lookword
+# nesting: `@`Look .`` — Exec unwraps it, `Look .` is parsed as the ordinary
+# Look builtin with an argument, and Look opens the directory (an existing
+# shell there, ls'd — zzmark.txt is only ever in that listing)
+press middle 49 3
+release middle 49 3
+wait 10000 zzmark.txt
+stable 700 15000
+snap nested
+# the same syntax typed into a TAG — the other place the expansion has to know
+# about backticks (tagChordText). The look above left the shell focused, so `:`
+# opens ITS tag; `i` then End walks to the end of the tail, and the chord runs
+# the word under the cursor exactly as the body's does.
+key :
+key i
+key end
+key space
+text Exec
+key left
+key tab
+stable 700 8000
+# ...except that word is `Exec` ITSELF, with nothing to execute. This is the
+# case that must not hang a re-entrant dispatcher: it runs out of argument
+# immediately and does nothing at all.
+snap execword
+key end
+key space
+text @`echo ZZ''T`
+key left
+key tab
+wait 10000 ZZT
+stable 700 15000
+snap tagcmd