From 65b207c3392c75eac3f2b18a266a6482d8345df1 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 28 Jul 2026 16:49:51 -0300 Subject: Builtins (Find, Grep) qol improvements and ui fixes --- src/pardes.zig | 250 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 170 insertions(+), 80 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index 731dfe0a..e29c3662 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -41,7 +41,11 @@ const scroll_off = 3; /// them for the vim reading. pub const swap_enter_tab = false; -pub const topbar_str = "Kill Newcol Tutor Debug NextColor Dump"; +// The two searches go LAST (before the optional `Restore `), and next to +// each other: they are one pair — Find matches file NAMES, Grep their CONTENTS +// — and every word's start column is what a topbar click dispatches on, so +// appending is the edit that leaves the bar people already know where it was. +pub const topbar_str = "Kill Newcol Tutor Debug NextColor Dump Find Grep"; const pane_builtins_str = "Del"; const file_pane_builtins_str = "Save Del"; const pane_tail = " " ++ pane_builtins_str; @@ -78,6 +82,9 @@ const Builtin = enum { Restore, Save, Find, + // Find's sibling: Find matches file NAMES under this pane's directory, + // Grep matches file CONTENTS under every pane's directory at once. + Grep, Del, Delcol, Help, @@ -107,6 +114,7 @@ const leader_path = std.EnumArray(Builtin, []const u8).init(.{ // which frees `w` for the window group (SPC w h/j/k/l) to move into. .Save = "fs", .Find = "ff", + .Grep = "fg", .Tutor = "ht", .Newcol = "cn", .Delcol = "cd", @@ -198,6 +206,14 @@ fn fileLineCount(content: []const u8) usize { return std.mem.count(u8, content, "\n") + 1; } +/// `a` IS `b` or lives under it — how Grep drops a pane's directory when +/// another pane's directory already covers it +fn pathUnder(a: []const u8, b: []const u8) bool { + const root = std.mem.trimEnd(u8, b, "/"); + if (!std.mem.startsWith(u8, a, root)) return false; + return a.len == root.len or a[root.len] == '/'; +} + const tutor_text = @embedFile("tutor.txt"); const rc_path = "/tmp/pardes-osc133.bash"; pub const bash_rc = @@ -1423,7 +1439,7 @@ pub const Pardes = struct { null; if (dir) |d| { const from = p.active; - p.runBuiltin(d, from, ""); + p.runBuiltin(d, from, "", null); if (p.active == from) { // above the topmost tagline is the TOPBAR — row 0, the global // one. It is not a pane, so focusDir can never reach it; this @@ -1612,6 +1628,32 @@ pub const Pardes = struct { return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); } + /// What an execute takes as its ARGUMENT: text selected ANYWHERE (acme — + /// the chord argument is whatever is selected, in any window), searched + /// `first` (the pane the execute happened in), then the active pane (making + /// a selection focuses its pane, so it holds the most recent one), then + /// slot order. Per pane a kept left selection wins, else an explicit modal + /// (v/x, n/N) one. Scratch-owned: dead at the next arena reset, so a caller + /// that keeps it (the 2-1 chord) copies. + fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { + var k: usize = 0; + while (k < p.panes.len + 2) : (k += 1) { + const i = switch (k) { + 0 => first, + 1 => p.active, + else => k - 2, + }; + if (k > 0 and (i == first or (k > 1 and i == p.active))) continue; + const t = p.panes[i] orelse continue; + if (t.sel[0].state == .done) { + if (p.selectionText(t, t.sel[0]) catch null) |tx| return tx; + } else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) { + if (p.currentSelText(t)) |tx| return tx; + } + } + return null; + } + 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. @@ -1629,7 +1671,7 @@ pub const Pardes = struct { // so this is the only keyboard way out of one. if (p.ctrl_w_pending) { p.ctrl_w_pending = false; - if (is(key, 'h') or is(key, Key.left)) p.runBuiltin(.Left, p.active, "") else if (is(key, 'l') or is(key, Key.right)) p.runBuiltin(.Right, p.active, "") else if (is(key, 'k') or is(key, Key.up)) p.runBuiltin(.Up, p.active, "") else if (is(key, 'j') or is(key, Key.down)) p.runBuiltin(.Down, p.active, ""); + if (is(key, 'h') or is(key, Key.left)) p.runBuiltin(.Left, p.active, "", null) else if (is(key, 'l') or is(key, Key.right)) p.runBuiltin(.Right, p.active, "", null) else if (is(key, 'k') or is(key, Key.up)) p.runBuiltin(.Up, p.active, "", null) else if (is(key, 'j') or is(key, Key.down)) p.runBuiltin(.Down, p.active, "", null); return; } // insert mode owns Ctrl-w (delete word backward, helix); the focus @@ -1772,7 +1814,7 @@ pub const Pardes = struct { p.leader_n += 1; const pfx = p.leader_keys[0..p.leader_n]; for (leader_rows) |row| { - if (std.mem.eql(u8, row.path, pfx)) return p.runBuiltin(row.cmd, p.active, ""); + if (std.mem.eql(u8, row.path, pfx)) return p.runBuiltin(row.cmd, p.active, "", null); } for (leader_rows) |row| { if (std.mem.startsWith(u8, row.path, pfx)) { @@ -2268,7 +2310,7 @@ pub const Pardes = struct { // file behind the pane and nothing worth keeping, so "never mind" // means dismiss it: Esc IS Del, the same builtin its tag offers. if (is(key, Key.escape)) { - if (pane.file) |f| if (f.output) return p.runBuiltin(.Del, p.active, ""); + if (pane.file) |f| if (f.output) return p.runBuiltin(.Del, p.active, "", null); pane.select = false; pane.pending = 0; pane.pending2 = 0; @@ -2581,6 +2623,8 @@ pub const Pardes = struct { /// differ in nothing but where the rows come from. const search_marker = " /"; const find_marker = " Find /"; + const grep_marker = " Grep /"; + const Search = enum { text, find, grep }; /// `/` (and the Find builtin) on any pane: type the pattern into the tag /// tail after `marker` — the existing modal one-line editor, visible while @@ -2604,14 +2648,21 @@ pub const Pardes = struct { /// Enter on an armed input: the pattern is everything past the marker's /// `/` (so a pattern may itself contain slashes), and the marker names the - /// search — " Find /" walks the filesystem, " /" reads the pane's text. - /// No `/` left means the editor ate the marker: nothing to run. + /// search — " Find /" walks the filesystem for NAMES, " Grep /" for + /// CONTENTS, " /" reads the pane's own text. No `/` left means the editor + /// ate the marker: nothing to run. fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const tail = pane.tag_tail.items; const armed = tail[@min(pane.search_at, tail.len)..]; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; - p.runSearch(id, armed[slash + 1 ..], std.mem.startsWith(u8, armed, find_marker)); + const kind: Search = if (std.mem.startsWith(u8, armed, find_marker)) + .find + else if (std.mem.startsWith(u8, armed, grep_marker)) + .grep + else + .text; + p.runSearch(id, armed[slash + 1 ..], kind); } /// Fill this pane's results buffer with everything matching `pat_raw` @@ -2624,8 +2675,11 @@ pub const Pardes = struct { /// by a SPACE (a trailing `:` would read as part of it): the pane's /// path when it has a real file, else `@pN`. /// find — the pane's DIRECTORY, walked like fd. Rows are bare paths. + /// grep — the CONTENTS of every file under every pane's directory, + /// walked like `grep -R`. Rows are `path:LINE:COL text`, the path + /// relative to THIS pane's directory (absolute for a hit outside it). /// No matches = an empty buffer. - fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, find: bool) void { + fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search) void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; @@ -2634,7 +2688,37 @@ pub const Pardes = struct { // root, and the directory the results buffer is named in. const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); var out: std.ArrayList(u8) = .empty; - if (find) look.find(arena, dir, pat, &out) else { + if (kind == .grep) { + // One walk per PLACE the session is open on: every pane's + // directory, minus the ones another pane's already contains, so a + // tree two panes sit in is greped once and a pane deep inside + // another's tree adds nothing. Slot order, so the same session + // gives the same buffer twice running. + var roots: [MAX_PANES][]const u8 = undefined; + var nroots: usize = 0; + for (p.panes) |slot| { + const pp = slot orelse continue; + const d = if (pp.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pp.cwdSlice(); + var covered = false; + var k: usize = 0; + while (k < nroots) { + if (pathUnder(d, roots[k])) { + covered = true; + break; + } + if (pathUnder(roots[k], d)) { // this one swallows a root already kept + nroots -= 1; + roots[k] = roots[nroots]; + continue; + } + k += 1; + } + if (covered) continue; + roots[nroots] = d; + nroots += 1; + } + for (roots[0..nroots]) |r| look.grep(arena, p.gpa, r, dir, pat, &out); + } else if (kind == .find) look.find(arena, dir, pat, &out) else { const pl = p.paneCursorLines(pane) catch return; // a real file names itself; a terminal or an output buffer has no path const has_path = if (pane.file) |f| !f.output else false; @@ -2654,31 +2738,11 @@ pub const Pardes = struct { } } const content = p.gpa.dupe(u8, out.items) catch return; - // reuse this pane's own results buffer while it lives, else open one - // next to the docs; focus stays here so n/N keeps stepping. search_pane - // is a raw slot index and a deleted buffer frees its slot, so the NAME - // is the proof the pane found there is still the one we made. - if (pane.search_pane) |sid| if (p.panes[sid]) |sp| if (sp.file) |*sf| if (sf.output and std.mem.endsWith(u8, sf.path, "/+Search")) { - p.setFileContent(sf, content); - sf.scroll = 0; - sp.cur_row = 0; - sp.msel.active = false; - pane.search_row = null; - // a new result count is a new height (splitBelow does this for a - // fresh one): trade rows with the neighbour it was split off, in - // BOTH directions so the pane grows back for a wider search. The - // neighbour keeps a tag + 2 rows whatever the hit count. - if (p.layoutFindTerm(sid)) |loc| if (p.col_n[loc.col] > 1) { - p.snapColWeights(loc.col); - if (p.panes[p.col_terms[loc.col][if (loc.idx > 0) loc.idx - 1 else 1]]) |sib| { - const pair = sp.vweight + sib.vweight; - const hi = @max(@as(f32, BOX_H + 1), pair - @as(f32, BOX_H + 2)); - sp.vweight = @min(hi, @as(f32, @floatFromInt(BOX_H + fileLineCount(std.mem.trimEnd(u8, content, "\n"))))); - sib.vweight = pair - sp.vweight; - } - }; - return; - }; + // every search opens its OWN buffer, even from the same pane: the + // earlier lists stay open at their sizes and the new one stacks + // directly below this pane, taking its rows from here (placeDoc). + // search_pane is the newest, so n/N step the search just run; focus + // stays here. const free = p.freeSlot() orelse { p.gpa.free(content); return; @@ -4081,32 +4145,11 @@ pub const Pardes = struct { .left => switch (m.kind) { .press => { // middle+left chord: a left press during a middle select- - // drag captures a selection as the execute's argument; the - // middle drag keeps running to its release. The drag pane's - // own selection wins; a pane with none borrows from the - // OTHER panes (acme: the chord argument is whatever text is - // selected, in any window) — the ACTIVE pane first (making - // a selection focuses its pane, so it holds the most - // recently made one), then slot order. Per pane a kept left - // selection wins, else an explicit modal (v/x, n/N) one. + // drag captures a selection (heldSelection) as the + // execute's argument; the middle drag keeps running to its + // release, which consumes it. if (p.drag == .select and p.drag.select.button == .middle) { - const did = p.drag.select.id; - var txt: ?[]const u8 = null; - var k: usize = 0; - while (txt == null and k < p.panes.len + 2) : (k += 1) { - const i = switch (k) { - 0 => did, - 1 => p.active, - else => k - 2, - }; - if (k > 0 and (i == did or (k > 1 and i == p.active))) continue; - const t = p.panes[i] orelse continue; - if (t.sel[0].state == .done) - txt = p.selectionText(t, t.sel[0]) catch null - else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) - txt = p.currentSelText(t); - } - if (txt) |tx| { + if (p.heldSelection(p.drag.select.id)) |tx| { if (p.chord_arg) |old| p.gpa.free(old); p.chord_arg = p.gpa.dupe(u8, tx) catch null; } @@ -4198,7 +4241,11 @@ pub const Pardes = struct { if (m.button == .middle) { var tb_buf: [1200]u8 = undefined; const word = wordAtCol(p.topbar(&tb_buf), mcol); - if (word.len > 0) p.actOnSelection(.middle, p.active, word, null); + // a topbar word runs on the PRESS — there is no + // 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(.middle, p.active, word, p.heldSelection(p.active)); } return; } @@ -4586,7 +4633,15 @@ pub const Pardes = struct { const f = p.layoutFindTerm(id) orelse return; if (p.col_n[f.col] <= 1) return; p.snapColWeights(f.col); - const sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; + // the pane above, but never a result list: walking past a stack of them + // lands on the pane that spawned it, so those keep their heights and + // only the spawner grows. The topmost pane has only what is below it. + var sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; + var k = f.idx; + while (k > 0) : (k -= 1) { + sib = p.col_terms[f.col][k - 1]; + if (p.panes[sib]) |pp| if (if (pp.file) |ff| !ff.output else true) break; + } if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); } @@ -4900,7 +4955,7 @@ pub const Pardes = struct { // 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, false); + 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 @@ -4953,12 +5008,24 @@ pub const Pardes = struct { if (button != .middle) return; const cmd = std.mem.trim(u8, txt, " \t\r\n"); if (cmd.len == 0) return; - const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse - // Restore is the ONE builtin that takes an argument (`Restore - // `); every other name must match whole, so `Kill foo` is a - // shell command and not the Kill builtin. - if (std.mem.startsWith(u8, cmd, "Restore ") or std.mem.startsWith(u8, cmd, "Restore\t")) .Restore else null; - if (bi) |b| return p.runBuiltin(b, id, cmd); + // three builtins take an ARGUMENT — `Restore `, `Find `, + // `Grep ` — so their name matches with a tail; every other name + // must match whole, so `Kill foo` is a shell command, 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, + 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); + } // 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| @@ -4981,12 +5048,15 @@ pub const Pardes = struct { } } - /// Run a builtin on pane `id`. `txt` is the executed text — only Restore - /// reads it (for its optional path argument), so the leader passes "" and - /// every builtin runs with zero arguments. The topbar ones are global; the - /// pane-scoped ones (Save, Del, Delcol, the image toggles, and the window - /// group, which moves focus relative to `id`) act on `id`. - fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8) void { + /// 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 + /// 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 + /// relative to `id`) act on `id`. + fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { const pane = p.panes[id] orelse return; switch (b) { .Debug => p.show_debug = !p.show_debug, @@ -5024,9 +5094,18 @@ pub const Pardes = struct { // an output buffer has no file behind it — nothing to write if (pane.file) |f| if (!f.output) p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); }, - // the one builtin that ASKS for something: it arms the same tag - // input `/` does, and Enter there runs the walk (submitSearch). - .Find => p.startSearch(pane, find_marker), + // The two builtins that ASK for something — Find walks file NAMES + // under this pane's directory, Grep file CONTENTS under every + // pane's. With an argument there is nothing to ask: it IS the + // pattern, so the walk runs now (this is what a `Grep` executed + // with a selection chorded to it means). Without one they arm the + // same tag input `/` does, and Enter runs it (submitSearch). + .Find, .Grep => { + const kind: Search = if (b == .Find) .find else .grep; + const pat = std.mem.trim(u8, arg orelse "", " \t\r\n"); + if (pat.len > 0) return p.runSearch(id, pat, kind); + p.startSearch(pane, if (b == .Find) find_marker else grep_marker); + }, .Newcol => { const free = p.freeSlot() orelse return; if (p.ncol >= MAX_COLS) return; @@ -5157,7 +5236,8 @@ pub const Pardes = struct { /// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a /// document: it is the result list belonging to the pane that asked for it, /// so it never claims a column and is never anyone else's split parent — it - /// lands right below `from_id`, be that a shell, a file or another list. + /// lands right below `from_id`, be that a shell, a file or another list, + /// and `from_id` alone pays the rows (several lists just stack there). /// A real doc joins the docs: any doc already open is the split parent (the /// source if it IS one, else the first found) and the newcomer lands right /// below it, so all docs share one column. The FIRST doc of the session @@ -5167,7 +5247,7 @@ pub const Pardes = struct { fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| !f.output else true; // an image is a doc var src_id: ?usize = null; - if (p.panes[from_id]) |src| if (!doc or (if (src.file) |f| !f.output else src.image != null)) { + if (p.panes[from_id]) |src| if (if (src.file) |f| !f.output else src.image != null) { src_id = from_id; }; if (doc and src_id == null) for (p.panes, 0..) |sl, i| { @@ -5176,6 +5256,16 @@ pub const Pardes = struct { break; }; }; + // A result list belongs to its spawner: it lands directly BELOW it — + // on top of the lists already there — and its rows come out of the + // SPAWNER, never a bystander, so opening another list (or deleting + // one, see absorbVWeight) leaves every other pane's height untouched. + if (!doc) if (p.layoutFindTerm(from_id)) |sf| { + p.layoutInsert(sf.col, sf.idx + 1, free); + p.splitBelow(from_id, nt); // NOT splitParent: no bystander pays + p.active = free; + return; + }; if (src_id) |sid| { const src = p.splitParent(sid); const sf = p.layoutFindTerm(src).?; -- cgit v1.3