diff options
| -rw-r--r-- | src/Mini.zig | 3 | ||||
| -rw-r--r-- | src/Output.zig | 7 | ||||
| -rw-r--r-- | src/builtins.zig | 15 | ||||
| -rw-r--r-- | src/exec.zig | 571 | ||||
| -rw-r--r-- | src/fs.zig | 1 | ||||
| -rw-r--r-- | src/look.zig | 17 | ||||
| -rw-r--r-- | src/mouse.zig | 25 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 2 | ||||
| -rw-r--r-- | src/ninep/events.zig | 3 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 3 | ||||
| -rw-r--r-- | src/normal.zig | 7 | ||||
| -rw-r--r-- | src/pardes.zig | 653 | ||||
| -rw-r--r-- | src/pdf_view.zig | 3 | ||||
| -rw-r--r-- | test/panes.zig | 6 | ||||
| -rw-r--r-- | test/pdf.zig | 2 |
15 files changed, 681 insertions, 637 deletions
diff --git a/src/Mini.zig b/src/Mini.zig index bc93f678..52e26a37 100644 --- a/src/Mini.zig +++ b/src/Mini.zig @@ -2,6 +2,7 @@ const panes = @import("panes.zig"); const std = @import("std"); const pardes = @import("pardes.zig"); +const exec = @import("exec.zig"); const config = @import("config.zig"); const Pardes = pardes.Pardes; const modal = @import("modal.zig"); @@ -187,7 +188,7 @@ pub fn open(p: *Pardes, id: usize, argument: []const u8) !void { .syntax_dirty = false, }; pane.cur_pinned = true; - p.placeDoc(id, free, pane); + exec.placeDoc(p, id, free, pane); p.active = free; } diff --git a/src/Output.zig b/src/Output.zig index 674fcbc8..b216c0cf 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -3,6 +3,7 @@ const panes = @import("panes.zig"); const std = @import("std"); const pardes = @import("pardes.zig"); +const exec = @import("exec.zig"); const Messages = @import("Messages.zig"); const config = @import("config.zig"); const Pardes = pardes.Pardes; @@ -479,7 +480,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: [] const free = p.freeSlot() orelse return error.NoPaneSlots; const np = try open(p, free, dir, from, arg, result_content); np.file.?.location_rows = result_rows; - p.placeDoc(id, free, np); + exec.placeDoc(p, id, free, np); p.active = id; if (traits(from).steps) { pane.search_pane = free; @@ -559,7 +560,7 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void { const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const free = p.freeSlot() orelse return error.NoPaneSlots; const np = try open(p, free, dir, from, "", content); - p.placeDoc(id, free, np); + exec.placeDoc(p, id, free, np); p.active = id; pane.search_pane = free; pane.search_row = null; @@ -778,6 +779,6 @@ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const free = p.freeSlot() orelse return error.NoPaneSlots; const np = try open(p, free, dir, from, arg, content); - p.placeDoc(id, free, np); + exec.placeDoc(p, id, free, np); p.active = free; } diff --git a/src/builtins.zig b/src/builtins.zig index 21dd4fa9..19be62e2 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1,6 +1,7 @@ //! Command structs and runtime settings form the builtin registry; bindings live in config.zig. const std = @import("std"); const pardes = @import("pardes.zig"); +const exec = @import("exec.zig"); const look = @import("look.zig"); const edit = @import("edit.zig"); const colors = @import("colors.zig"); @@ -155,7 +156,7 @@ pub const registry = struct { inline for (manualBuiltinList(), 0..) |T, i| if (@intFromEnum(b) == i) return T.run(c); inline for (comptime settingList(), manualBuiltinCount()..) |setting, i| - if (@intFromEnum(b) == i) return c.p.applySettingBuiltin(setting, c.arg); + if (@intFromEnum(b) == i) return exec.applySettingBuiltin(c.p, setting, c.arg); unreachable; } }; @@ -223,7 +224,7 @@ 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); + _ = exec.execute(c.p, c.id, c.arg orelse return); } }; @@ -478,8 +479,8 @@ pub const Save = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { const path = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); - if (path.len > 0) return c.p.saveTo(c.id, path); - if (c.pane.file) |file| if (file.output == null) return c.p.saveFile(c.id); + if (path.len > 0) return exec.saveTo(c.p, c.id, path); + if (c.pane.file) |file| if (file.output == null) return exec.saveFile(c.p, c.id); if (c.pane.file != null or c.pane.isTerminal()) c.p.startPrompt(c.pane, .save); } }; @@ -585,7 +586,7 @@ pub const DelBelow = struct { /// pane's tagline: the fast path from wherever you are to a prompt there. pub const Tty = struct { pub fn run(c: Ctx) void { - c.p.spawnTty(c.id); + exec.spawnTty(c.p, c.id); } }; @@ -593,7 +594,7 @@ pub const Tty = struct { pub const Tty9p = struct { pub const enabled = pardes.hosted and @import("builtin").os.tag == .linux; pub fn run(c: Ctx) void { - c.p.spawnV9fsTty(c.id); + exec.spawnV9fsTty(c.p, c.id); } }; @@ -609,7 +610,7 @@ pub const Tutor = struct { pub fn run(c: Ctx) void { const free = c.p.freeSlot() orelse return; const nt = c.p.openTutorView(free) catch return; - c.p.placeDoc(c.id, free, nt); // a doc like any other + exec.placeDoc(c.p, c.id, free, nt); // a doc like any other } }; diff --git a/src/exec.zig b/src/exec.zig new file mode 100644 index 00000000..75d07441 --- /dev/null +++ b/src/exec.zig @@ -0,0 +1,571 @@ +//! Executing a word (acme's exec.c): a builtin line or a command typed into +//! a terminal, the builtin dispatch and settings, the operand a click or +//! chord hands over, the terminals commands run in, placing a document, and +//! saving a pane. +const pardes = @import("pardes.zig"); +const std = @import("std"); +const layout = @import("layout.zig"); +const modal = @import("modal.zig"); +const look = @import("look.zig"); +const panes = @import("panes.zig"); +const normal = @import("normal.zig"); +const edit = @import("edit.zig"); +const body_layer = @import("body_layer.zig"); +const colors = @import("colors.zig"); +const themes = colors.themes; +const builtins = @import("builtins.zig"); +const config = @import("config.zig"); +const pdf_enabled = panes.Pdf.enabled; +const font_picker = pardes.font_picker; +const hosted = pardes.hosted; +const fonts = pardes.fonts; +const Pane = panes.Pane; +const MAX_COLS = pardes.MAX_COLS; +const BOX_H = pardes.BOX_H; +const Builtin = pardes.Builtin; +const Key = pardes.Key; +const sel_slot = pardes.sel_slot; +const Pardes = pardes.Pardes; + +pub fn takesCommandLine(p: *const Pardes, id: usize) bool { + const pane = p.panes[id] orelse return false; + if (!pane.isTerminal()) return false; + // A mounted shell sits behind sudo's process supervisor. Its OSC 133 + // prompt marks identify input readiness; the launcher's executable is + // not the shell executable used by the ordinary process heuristic. + if (pane.v9fs_on_spawn) return panes.Terminal.promptInputReady(pane); + return !p.hostTtyTaken(id); +} + +pub fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool { + if (pane.nsel == 0) return false; + const text = edit.flatSurface(p, pane) catch return false; + var ranges: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(text, 0, &ranges); + if (got.n < 2) return false; + + var texts: [Pane.max_selections][]u8 = undefined; + var made: usize = 0; + defer for (texts[0..made]) |t| p.gpa.free(t); + for (ranges[0..got.n]) |range| { + const lo = @min(range.anchor, range.head); + const hi = @max(range.anchor, range.head); + if (hi > text.len) break; + texts[made] = p.gpa.dupe(u8, text[lo..hi]) catch break; + made += 1; + } + // All or nothing, like the pipe: half a chord is not a chord. + if (made != got.n) return false; + + const id = p.active; + const serial = pane.serial; + pane.vsel.active = false; + pane.msel.active = false; + pane.select = false; + pane.nsel = 0; // the chord consumed them, exactly as it consumes one + + var primary_active: ?usize = null; + for (texts[0..made], 0..) |txt, i| { + runBuiltin(p, cmd, id, "", txt); + if (i == got.pri) primary_active = p.active; + const still = p.panes[id] orelse break; + if (still.serial != serial) break; + } + if (primary_active) |a| if (p.panes[a] != null) { + p.active = a; + }; + return true; +} + +pub const PointerOperand = struct { + /// Absolute body position corresponding to the pointed screen cell. + row: i32, + col: i32, + /// Exactly what a no-drag middle/right click will dispatch. + text: ?[]const u8 = null, + /// What hover paints. Null names the pane's live modal selection. + preview: ?Pane.Sel = null, + /// File words stay logical so a soft-wrapped operand is not cut into + /// unrelated rendered fragments. Null for tags and non-file panes. + file_word: ?look.FileWordSpan = null, + /// A newly expanded word is installed in the gesture's button slot; + /// an existing selection is only borrowed and must not replace it. + expanded: ?Pane.Sel = null, +}; + +pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand { + const visible = clicked.r0 - @as(i32, BOX_H); + const wrapped = pane.wrapAt(visible); + const raw = pane.isTerminal() and pane.mode == .tty and clicked.r0 >= BOX_H; + const row = if (raw) panes.Terminal.gridOffset(pane) + visible else wrapped.line; + const raw_line = if (raw) modal.lineSlice(body_layer.bodyText(p, p.scratch.allocator(), pane) catch "", @intCast(@max(0, visible))) else ""; + const col = if (raw) + @as(i32, @intCast(panes.File.rawAtDisplay(raw_line, @intCast(@max(0, clicked.c0))))) + else if (clicked.r0 >= BOX_H) + edit.paneByteAtDisplay( + p, + pane, + wrapped.line, + wrapped.at, + clicked.c0 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), + ) + else + clicked.c0; + var result: PointerOperand = .{ .row = row, .col = col }; + + const kept = pane.sel[sel_slot]; + const inside_kept = if (pane.pointerSelection(kept)) |rows| inside: { + if (clicked.r0 < BOX_H) break :inside false; + if (pane.file != null and clicked.c0 < panes.File.gutterWidth(pane)) break :inside false; + const source_col: usize = @intCast(@max(0, col)); + for (rows) |span| { + const target = if (span.raw_terminal) + (if (raw) span.row else pane.surfRow(span.row)) + else + (if (raw) pane.gridRow(span.row) else span.row); + var lo = span.lo; + var hi = span.hi; + if (span.raw_terminal and !raw) { + lo -|= span.prompt_bytes; + hi -|= span.prompt_bytes; + } else if (!span.raw_terminal and raw) { + const prefix = panes.Terminal.promptPrefixBytes(pane, row, raw_line); + lo += prefix; + hi += prefix; + } + if (target == row and source_col >= lo and source_col < hi) + break :inside true; + } + break :inside false; + } else edit.spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1); + if (kept.state == .done and inside_kept) { + result.text = edit.selectionText(p, pane, kept) catch null; + result.preview = kept; + return result; + } + if (pane.vsel.active and pane.vsel.explicit and + edit.spanHas(row, col, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col)) + { + result.text = edit.currentSelText(p, pane); + return result; + } + if (pane.msel.active and row >= @min(pane.msel.r0, pane.msel.r1) and + row <= @max(pane.msel.r0, pane.msel.r1)) + { + result.text = edit.currentSelText(p, pane); + return result; + } + if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { + const line = panes.File.sourceLine(pane, row); + const source_col: usize = @intCast(@max(0, col)); + const b = look.expandedWord(line, source_col) orelse return result; + const lo = std.math.cast(i32, b.lo) orelse return result; + const hi = std.math.cast(i32, b.hi) orelse return result; + result.text = line[b.lo..b.hi]; + result.file_word = .{ .row = row, .lo = lo, .hi = hi }; + + var expanded = clicked; + const first = pane.wrapRow(row, lo); + const last = pane.wrapRow(row, hi - 1); + if (first.row >= 0 and last.row >= 0) { + expanded.r0 = first.row + @as(i32, BOX_H); + expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, lo); + expanded.r1 = last.row + @as(i32, BOX_H); + expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1); + } + result.preview = expanded; + result.expanded = expanded; + return result; + } + + const expanded = look.expandedSel(p, pane, clicked) orelse return result; + result.text = edit.selectionText(p, pane, expanded) catch null; + if (result.text == null or result.text.?.len == 0) return result; + result.preview = expanded; + result.expanded = expanded; + return result; +} + +pub 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 (comptime pdf_enabled) if (p.native_images) if (t.pdf) |pv| { + if (pv.selection != null and pv.selection_text.len > 0) + return pv.selection_text; + }; + if (t.sel[sel_slot].state == .done) { + if (edit.selectionText(p, t, t.sel[sel_slot]) catch null) |tx| return tx; + } else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) { + if (edit.currentSelText(p, t)) |tx| return tx; + } + } + return null; +} + +pub 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; +} + +/// Enter on a save input: the path is everything past the marker. +pub fn submitSave(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const at = switch (pane.prompt) { + .save => |a| a, + else => return, + }; + const armed = pane.tag_tail[at..pane.tag_tail_len]; + pane.tag_tail_len = at; // drop the prompt back to the seeded tail + if (!std.mem.startsWith(u8, armed, config.save_marker)) return; + const path = std.mem.trim(u8, armed[config.save_marker.len..], " \t\r\n"); + if (path.len == 0) return; + saveTo(p, id, path); +} + +/// Write the file this pane already has: acme's Put, and the one Save that +/// needs no argument because the path was never a question. +pub fn saveFile(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const f = if (pane.file) |*file| file else return; + if (f.output != null) return; // nothing behind it yet: saveTo, with a path + p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); +} + +pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void { + const pane = p.panes[id] orelse return; + if (path.len == 0 or path[path.len - 1] == '/' or + std.mem.indexOfScalar(u8, path, '\n') != null) + return p.reportError(id, "save", error.NoFilename); + const full = std.fs.path.resolvePosix( + p.scratch.allocator(), + &.{ Pardes.paneDir(pane), path }, + ) catch return; + if (!std.fs.path.isAbsolute(full)) return p.reportError(id, "save", error.NoDirectory); + if (full.len > Pardes.SavePath.cap) return p.reportError(id, "save", error.PathTooLong); + const f = if (pane.file) |*file| file else { + // a terminal writes its scrollback; an image and a PDF have nothing + // of their own that is unwritten, so they have nothing to write + if (pane.isTerminal()) askWrite(p, id, pane.serial, full); + return; + }; + if (f.output != null and panes.Output.fileTraits(f.output).saves) { + const owned = p.gpa.dupe(u8, full) catch return; + p.gpa.free(f.path); + f.path = owned; + f.output = null; // an ordinary file pane from here on + f.watch_after_save = true; + pane.clearCwd(); + pane.tag_init = false; // re-derive the tag as a plain file + pane.tag_tail_len = 0; + p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); + return; + } + // its own path, spelled out: the in-place write, so the pane comes clean + if (f.output == null and std.mem.eql(u8, f.path, full)) return saveFile(p, id); + askWrite(p, id, pane.serial, full); +} + +fn askWrite(p: *Pardes, id: usize, serial: u32, path: []const u8) void { + p.emit(.{ .save_text = .{ + .pane = @intCast(id), + .serial = serial, + .path = Pardes.SavePath.from(path), + } }); +} + +/// Tty: a shell in the caller's directory, raw from the first frame, +/// stacked below the caller like Alt-n's new shell. +pub fn spawnTty(p: *Pardes, from: usize) void { + spawnTtyWithMount(p, from, false); +} + +pub fn spawnV9fsTty(p: *Pardes, from: usize) void { + if (comptime !hosted or @import("builtin").os.tag != .linux) + return p.reportError(from, "Tty9p", error.LinuxRequired); + if (p.fs.socket_path.len == 0) + return p.reportError(from, "Tty9p", error.SessionSocketRequired); + spawnTtyWithMount(p, from, true); +} + +fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) void { + const src = p.panes[from] orelse return; + const free = p.freeSlot() orelse return; + const nt = p.newShell(free, Pardes.paneDir(src)) catch return; + nt.greet = !v9fs; + nt.v9fs_on_spawn = v9fs; + nt.mode = .tty; + const parent = layout.splitParent(p, from); + const f = layout.findPane(p, parent).?; + layout.insert(p, f.col, f.idx + 1, free); + layout.splitBelow(p, parent, nt); + p.active = free; + p.stillborn_joiner = nt.serial; +} + +/// when a doc lands in `col`, a lone pristine shell there is clutter — drop +/// it; absorbVWeight hands its space to the doc +pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { + var n_tty: usize = 0; + var tty_id: usize = 0; + for (0..p.col_n[col]) |k| { + const cid = p.col_panes[col][k]; + if (p.panes[cid]) |ct| if (ct.isTerminal()) { + n_tty += 1; + tty_id = cid; + }; + } + if (n_tty != 1) return; + const tt = p.panes[tty_id] orelse return; + // No typing, cursor on the first prompt line, no scrollback: this is + // the throwaway boot placeholder a document may replace. + if (tt.ovl != null or panes.Terminal.gridCursor(tt).y != 0 or + panes.Terminal.scrollbar(tt).total > tt.rows) return; + p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err); + layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows + layout.absorbVWeight(p, tty_id, null); + layout.removePane(p, tty_id); + p.panes[tty_id] = null; + if (p.active == tty_id) p.active = keep_id; +} + +/// A pane that just joined a column whose only other pane is stillborn — +/// an empty scratch nobody typed into, body or tag (what Newcol makes and +/// what a closed column's last pane leaves behind) — takes the column +/// whole: the placeholder was only holding the space for it. Run at the +/// end of a step: the pane that asked for the joiner is often the +/// placeholder itself, and its caller is still holding it. +pub fn replaceStillborn(p: *Pardes) void { + const serial = p.stillborn_joiner orelse return; + p.stillborn_joiner = null; + const new_id = p.paneBySerial(serial) orelse return; + const f = layout.findPane(p, new_id) orelse return; + if (p.col_n[f.col] != 2) return; + const old_id = p.col_panes[f.col][1 - f.idx]; + const old = p.panes[old_id] orelse return; + const file = old.file orelse return; + const out = file.output orelse return; + if (out.from != .cmd or out.from.cmd != .New) return; + if (file.revision != 0 or file.content.len != 0) return; + if (old.tag_init and !std.mem.eql(u8, old.tagSlice(), Pardes.defaultTail(old))) return; + const was_active = p.active == old_id; + layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows + p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err); + if (was_active) p.active = new_id; +} + +fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { + for (p.panes, 0..) |slot, i| if (slot) |tt| { + if (!std.mem.eql(u8, tt.cwdSlice(), dir)) continue; + if (takesCommandLine(p, i)) return i; + }; + const free = p.freeSlot() orelse return null; + const nt = p.newShell(free, dir) catch return null; + nt.greet = false; + const rc = if (p.ncol > 0) p.ncol - 1 else 0; + if (p.col_n[rc] > 0) { + const src = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); + const f = layout.findPane(p, src).?; + layout.splitBelow(p, src, nt); + layout.insert(p, f.col, f.idx + 1, free); + } else layout.insert(p, rc, p.col_n[rc], free); + return free; +} + +const max_exec_depth = 8; + +pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { + const pane = p.panes[id] orelse return null; + const cmd = commandText(txt); + if (cmd.len == 0) return null; + if (p.exec_depth == 0) p.noteHaptic(.exec); + if (executeBuiltinLine(p, id, cmd)) return null; + if (p.exec_depth >= max_exec_depth) return null; + p.exec_depth += 1; + defer p.exec_depth -= 1; + const dst = (if (takesCommandLine(p, id)) id else ttyForDir(p, Pardes.paneDir(pane))) orelse return null; + panes.Terminal.padOutputBelowEdits(p, dst); + if (panes.Terminal.queuePendingCommand(p.panes[dst].?, cmd) catch |err| { + p.reportError(id, "queue command", err); + return dst; + }) return dst; + p.emitWrite(dst, cmd); + p.emitWrite(dst, "\r"); + return dst; +} + +/// Trim and unwrap clickable command-word notation once for every command +/// consumer, so startup lines and interactive Exec speak the same syntax. +pub fn commandText(txt: []const u8) []const u8 { + var cmd = std.mem.trim(u8, txt, " \t\r\n"); + while (config.commandWord(cmd)) |inner| cmd = std.mem.trim(u8, inner, " \t\r\n"); + return cmd; +} + +pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { + const cmd = commandText(txt); + if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false; + 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 if (builtins.registry.takesArg(head)) head else null; + }; + const b = bi orelse return false; + p.exec_depth += 1; + defer p.exec_depth -= 1; + const typed = if (sp) |s| std.mem.trim(u8, cmd[s + 1 ..], " \t") else ""; + runBuiltin(p, b, id, cmd, if (typed.len > 0) typed else null); + return true; +} + +pub fn applyStartupConfig(p: *Pardes) void { + p.announce = false; + defer p.announce = true; + var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n'); + while (lines.next()) |line| _ = executeBuiltinLine(p, p.active, line); +} + +pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { + if (!normal.multiOnce(p)) return; // a builtin is per-keystroke, never per-cursor + const pane = p.panes[id] orelse return; + // A builtin says its own name before it runs, so a tagline click is + // legible even when what it did is invisible. Whatever the builtin + // then says for itself replaces the announcement. + if (p.announce and p.settings.verbose and builtins.registry.announces(b)) { + p.setMessage(id, @tagName(b)); + pane.msg_announcement = true; + } + const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; + builtins.registry.dispatch(b, c); +} + +pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void { + const previous_colors = p.settings.colors; + const previous_context_style = p.settings.tree_context_tag_style; + const previous_transition = p.settings.panel_transition; + const previous_tagline_percent = p.settings.font.tagline_percent; + const previous_column_tags = p.settings.column_tags; + switch (setting.action) { + .theme => { + const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); + for (themes, 0..) |t, i| if (std.mem.eql(u8, t.name, want)) { + colors.setThemeIndex(p, i); + return; + }; + }, + .font => { + if (comptime !font_picker) return; + const spec = config.Runtime.FontSpec.parse(arg orelse return) orelse return; + const matches = fonts.list(p.scratch.allocator(), spec.name); + if (matches.len == 0) return; + if (!p.settings.requestFont(matches[0].path, matches[0].name, spec.size_hundredths)) return; + p.font_request_taken = false; + }, + else => _ = p.settings.apply(setting, arg), + } + if (p.settings.colors != previous_colors) { + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.file) |*file| file.syntax_dirty = true; + } + } + if (p.settings.tree_context_tag_style != previous_context_style) { + for (p.panes, 0..) |slot, id| { + p.surface.body_layers[id].rows = 0; + const pane = slot orelse continue; + pane.body_rows = 0; + pane.body_visible_rows = 0; + pane.context_row_limit = null; + pane.wrap_n = 0; + } + p.abandonPanelAnimations(); + } + const transition_changed = setting.action == .transition and + p.settings.panel_transition != previous_transition; + const tagline_metrics_changed = setting.action == .tagline_size and + p.settings.font.tagline_percent != previous_tagline_percent; + if (p.settings.column_tags != previous_column_tags) { + if (p.header_column != null) p.exitHeader(); + } + if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) { + p.abandonPanelAnimations(); + } +} + +pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { + p.stillborn_joiner = nt.serial; + const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc + var src_id: ?usize = null; + if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) { + src_id = from_id; + }; + if (doc and src_id == null) { + var n = p.njumps; + while (n > 0) : (n -= 1) { + const j = p.jumps[n - 1]; + const pp = p.panes[j.pane] orelse continue; + if (pp.serial != j.serial) continue; + if (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf()) { + src_id = j.pane; + break; + } + } + } + if (doc and src_id == null) for (p.panes, 0..) |sl, i| { + if (sl) |pp| if (i != free and (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf())) { + src_id = i; + break; + }; + }; + if (!doc) if (layout.findPane(p, from_id)) |sf| { + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, from_id, nt); // NOT splitParent: no bystander pays + p.active = free; + return; + }; + if (src_id == null and doc and p.ncol < MAX_COLS and + layout.columnFitsHalves(p, from_id, 100) and layout.splitColumn(p, from_id, free, true)) + { + p.active = free; + return; + } + // Without room for a first-document column, split the Look origin + // just like Tty. Never displace (or evict) a top-left bystander. + const src = layout.splitParent(p, src_id orelse from_id); + const sf = layout.findPane(p, src).?; + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, src, nt); + p.active = free; +} + +test "the acme chords act once per selection, not once on the primary" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + // Two selections, each naming a DIFFERENT builtin, so what ran is visible + // in the layout rather than in a shell nobody can read from a test. + const pane = try p.setTestFile("Newcol\nNewcol\n"); + const ranges = [_]modal.Selection{ + .{ .anchor = 0, .head = 6 }, + .{ .anchor = 7, .head = 13 }, + }; + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); + try std.testing.expectEqual(@as(u8, 1), pane.nsel); + + const before = p.ncol; + p.update(.{ .key = .{ .cp = Key.tab } }); // config.exec_key: Exec + // BOTH ran. Before this the chord read the primary range and dropped the + // other cursor, so one keystroke over two cursors made one column. + try std.testing.expectEqual(before + 2, p.ncol); + // ...and the chord consumed the selection exactly as it does with one. + try std.testing.expectEqual(@as(u8, 0), p.panes[0].?.nsel); +} @@ -43,6 +43,7 @@ const source_files = [_]Source{ .{ .path = "src/edit.zig", .contents = @embedFile("edit.zig") }, .{ .path = "src/normal.zig", .contents = @embedFile("normal.zig") }, .{ .path = "src/mouse.zig", .contents = @embedFile("mouse.zig") }, + .{ .path = "src/exec.zig", .contents = @embedFile("exec.zig") }, .{ .path = "src/File.zig", .contents = @embedFile("File.zig") }, .{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") }, .{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") }, diff --git a/src/look.zig b/src/look.zig index af0905a4..1534d332 100644 --- a/src/look.zig +++ b/src/look.zig @@ -2,6 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; const pardes = @import("pardes.zig"); +const exec = @import("exec.zig"); const config = @import("config.zig"); const pdf_enabled = @import("pardes_config").mupdf; const fs = @import("fs.zig"); @@ -528,7 +529,7 @@ fn lookFirstHit(p: *Pardes, id: usize) void { rp.vsel.active = false; rp.msel.active = false; rp.select = false; - p.runBuiltin(config.look_cmd, rid, "", txt); + exec.runBuiltin(p, config.look_cmd, rid, "", txt); } pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void { @@ -686,7 +687,7 @@ pub fn jumpResult(p: *Pardes, id: usize, row: usize) bool { rp.look_at = null; } if (tr.commands) { - p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r")); + exec.runBuiltin(p, config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r")); } else if (span) |selected| { lookAt(p, rid, ln[selected.start..selected.end]); // This is the newly visited result, not a selection carried into @@ -922,7 +923,7 @@ pub fn advanceLookHover(p: *Pardes) void { pane.pdf.?.invalidateRaster(probe.page); return; }; - const operand = p.pointerOperand(pane, pointed.sel); + const operand = exec.pointerOperand(p, pane, pointed.sel); if (operand.text == null or operand.text.?.len == 0) return cancelLookHover(p); var preview = operand.preview; if (preview) |*sel| sel.state = .done; @@ -1136,7 +1137,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { noteLookSource(p, id); const command = std.mem.trim(u8, operand, " \t\r\n"); if (config.commandWord(command)) |cmd| { - if (p.execute(id, cmd)) |dst| p.active = dst; + if (exec.execute(p, id, cmd)) |dst| p.active = dst; return; } // The operand may borrow selected terminal/PDF text released below. @@ -1171,7 +1172,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { for (p.panes, 0..) |slot, i| { - if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and p.takesCommandLine(i)) { + if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and exec.takesCommandLine(p, i)) { clearNavigationSelection(p, tt); p.active = i; p.emitWrite(i, "ls\r"); @@ -1194,7 +1195,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); - p.placeDoc(id, free, nt); + exec.placeDoc(p, id, free, nt); return; }; // focus an existing pane on this path (rescrolled), else open @@ -1203,7 +1204,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1); - p.placeDoc(id, free, nt); + exec.placeDoc(p, id, free, nt); layout.compute(p); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; if (target.at.end_line != 0) { @@ -1217,7 +1218,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = panes.Image.create(p, free, target.path, &.{}) catch |err| return p.reportError(id, "look", err); - p.placeDoc(id, free, nt); + exec.placeDoc(p, id, free, nt); }, } } diff --git a/src/mouse.zig b/src/mouse.zig index 27d5dd9c..2a00bded 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -2,6 +2,7 @@ //! releases (acme's chords, border and seam drags, gestures), and the //! chrome targets a shell asks about. const pardes = @import("pardes.zig"); +const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); const layout = @import("layout.zig"); @@ -341,9 +342,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { if (word.len > 0) { if (m.button == config.exec_button) { const named = std.meta.stringToEnum(Builtin, word); - const held = if (named) |b| (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) else p.heldSelection(p.active); - _ = p.execute(p.active, p.withArg(word, held)); - } else p.runBuiltin(config.look_cmd, p.active, "", word); + const held = if (named) |b| (if (builtins.registry.takesArg(b)) exec.heldSelection(p, p.active) else null) else exec.heldSelection(p, p.active); + _ = exec.execute(p, p.active, exec.withArg(p, word, held)); + } else exec.runBuiltin(p, config.look_cmd, p.active, "", word); } } return; @@ -389,7 +390,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { config.select_button => switch (m.kind) { .press => { if (p.drag == .select and p.drag.select.button == config.exec_button) { - if (p.heldSelection(p.drag.select.id)) |tx| { + if (exec.heldSelection(p, p.drag.select.id)) |tx| { if (p.chord_arg) |old| p.gpa.free(old); p.chord_arg = p.gpa.dupe(u8, tx) catch null; } @@ -629,7 +630,7 @@ fn reportGesture( cmd: Builtin, text: []const u8, on_tag: bool, - operand: Pardes.PointerOperand, + operand: exec.PointerOperand, chorded: bool, ) bool { const scripted = p.panes[id] orelse return false; @@ -639,7 +640,7 @@ fn reportGesture( (if (on_tag) .tag_look else .body_look) else (if (on_tag) .tag_exec else .body_exec); - const named = std.meta.stringToEnum(Builtin, Pardes.commandText(text)) != null; + const named = std.meta.stringToEnum(Builtin, exec.commandText(text)) != null; const range = gestureRange(p, id, text, on_tag, operand); var flag: u32 = if (named) ctlfs.events.flag_builtin else 0; if (range.q0 == range.q1 and text.len > 0) flag |= ctlfs.events.flag_expansion; @@ -649,7 +650,7 @@ fn reportGesture( return ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text); } -fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: Pardes.PointerOperand) ctlfs.pane.State.Range { +fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: exec.PointerOperand) ctlfs.pane.State.Range { const pane = p.panes[id] orelse return .{}; if (on_tag) { const tag = p.tagText(p.scratch.allocator(), pane) catch return .{}; @@ -697,7 +698,7 @@ fn dispatchPointerBuiltin( id: usize, cmd: Builtin, text: ?[]const u8, - gesture: ?struct { on_tag: bool, operand: Pardes.PointerOperand }, + gesture: ?struct { on_tag: bool, operand: exec.PointerOperand }, ) void { const arg = p.chord_arg; p.chord_arg = null; @@ -707,7 +708,7 @@ fn dispatchPointerBuiltin( return; }; if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg != null)) return; - p.runBuiltin(cmd, id, "", p.withArg(operand, arg)); + exec.runBuiltin(p, cmd, id, "", exec.withArg(p, operand, arg)); } fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { @@ -769,7 +770,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { // a file moved into the left column evicts a lone unused shell if (p.panes[d.id]) |mt| if (mt.file != null) { if (layout.findPane(p, d.id)) |f| if (f.col == 0) - p.evictLonePristineTty(0, d.id); + exec.evictLonePristineTty(p, 0, d.id); }; }, .select => |s| { @@ -848,11 +849,11 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { pane.sel[b].state = .none; var buf: [48]u8 = undefined; const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ s.id, @as(usize, @intCast(line)) + 1 }) catch return; - p.runBuiltin(config.look_cmd, s.id, "", target); + exec.runBuiltin(p, config.look_cmd, s.id, "", target); return; } }; - var operand = p.pointerOperand(pane, clk); + var operand = exec.pointerOperand(p, pane, clk); if (operand.expanded) |expanded| pane.sel[b] = expanded else if (operand.text == null) diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 25fe4037..645b1c33 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -59,7 +59,7 @@ fn activeSerial(p: *Pardes) ?u32 { fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { const context = p.panes[id].?.serial; const before = Serials.snapshot(p); - if (exec) _ = p.execute(id, text) else look.lookAt(p, id, text); + if (exec) _ = pardes.exec.execute(p, id, text) else look.lookAt(p, id, text); const after = Serials.snapshot(p); var created = false; for (after.list[0..after.len]) |serial| if (!before.has(serial)) { diff --git a/src/ninep/events.zig b/src/ninep/events.zig index d4d7fcad..49e852b3 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -2,6 +2,7 @@ //! bounded queues that park a read until something happens. const std = @import("std"); const pardes = @import("../pardes.zig"); +const exec = @import("../exec.zig"); const look = @import("../look.zig"); const tree = @import("tree.zig"); const pane_files = @import("pane.zig"); @@ -461,7 +462,7 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue; switch (r.action) { - .body_exec, .tag_exec => _ = p.execute(live, text), + .body_exec, .tag_exec => _ = exec.execute(p, live, text), .body_look, .tag_look => look.lookAt(p, live, text), else => unreachable, } diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 4110380a..76854dbc 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -3,6 +3,7 @@ //! mark and scroll, errors, event, look and exec. Terminals add pty/ (pty.zig). const std = @import("std"); const pardes = @import("../pardes.zig"); +const exec = @import("../exec.zig"); const look = @import("../look.zig"); const panes = @import("../panes.zig"); const modal = @import("../modal.zig"); @@ -589,7 +590,7 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { p.gpa.free(content); return null; }; - p.placeDoc(id, free, np); + exec.placeDoc(p, id, free, np); return text.len; } diff --git a/src/normal.zig b/src/normal.zig index e273f7f1..6647dcca 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -2,6 +2,7 @@ //! modal actions, replayed across every selection, and each action is done //! to the pane; PDF panes get their own normal keys. const pardes = @import("pardes.zig"); +const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); const mvzr = @import("mvzr"); @@ -302,7 +303,7 @@ fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: modal.Normal.Action) v switch (semantic) { .escape => { pane.select = false; - return p.runBuiltin(.Last, p.active, "", null); + return exec.runBuiltin(p, .Last, p.active, "", null); }, .goto => |go| switch (go.target) { .file_start => { @@ -566,11 +567,11 @@ pub fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void { if (hit(key, config.leave_pane)) { pane.normal.clear(); pane.select = false; - return p.runBuiltin(.Last, p.active, "", null); + return exec.runBuiltin(p, .Last, p.active, "", null); } if (pane.normal.prefix == .none and isPrefix(key, 'f')) { pane.normal.clear(); - return p.runBuiltin(.PdfSections, p.active, "", null); + return exec.runBuiltin(p, .PdfSections, p.active, "", null); } const parsed = modal.Normal.parse(&pane.normal, normalInput(key)); switch (parsed) { diff --git a/src/pardes.zig b/src/pardes.zig index bdde1455..75766827 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -11,6 +11,7 @@ pub const syntax = @import("syntax.zig"); pub const locations_config = @import("locations_config.zig"); const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); +pub const exec = @import("exec.zig"); pub const mouse = @import("mouse.zig"); pub const normal = @import("normal.zig"); pub const edit = @import("edit.zig"); @@ -435,6 +436,7 @@ pub const Loc = struct { }; test { + _ = @import("exec.zig"); _ = @import("look.zig"); _ = @import("mouse.zig"); _ = @import("normal.zig"); @@ -497,7 +499,7 @@ pub const test_api = if (@import("builtin").is_test) struct { arg: ?[]const u8, ) bool { const command = std.meta.stringToEnum(Builtin, name) orelse return false; - p.runBuiltin(command, id, txt, arg); + exec.runBuiltin(p, command, id, txt, arg); return true; } @@ -510,7 +512,7 @@ pub const test_api = if (@import("builtin").is_test) struct { } pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { - return p.heldSelection(first); + return exec.heldSelection(p, first); } } else struct {}; @@ -581,30 +583,6 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le try std.testing.expect(!p.quit); } -test "the acme chords act once per selection, not once on the primary" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - // Two selections, each naming a DIFFERENT builtin, so what ran is visible - // in the layout rather than in a shell nobody can read from a test. - const pane = try p.setTestFile("Newcol\nNewcol\n"); - const ranges = [_]modal.Selection{ - .{ .anchor = 0, .head = 6 }, - .{ .anchor = 7, .head = 13 }, - }; - pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); - try std.testing.expectEqual(@as(u8, 1), pane.nsel); - - const before = p.ncol; - p.update(.{ .key = .{ .cp = Key.tab } }); // config.exec_key: Exec - // BOTH ran. Before this the chord read the primary range and dropped the - // other cursor, so one keystroke over two cursors made one column. - try std.testing.expectEqual(before + 2, p.ncol); - // ...and the chord consumed the selection exactly as it does with one. - try std.testing.expectEqual(@as(u8, 0), p.panes[0].?.nsel); -} - test "startup config runs builtin lines in order and isolates bad lines" { const p = try Pardes.init(std.testing.allocator, .{ .startup_config = @@ -1188,11 +1166,11 @@ test "Tty9p marks only the new Linux terminal for a mounted shell" { try std.testing.expect(pane.isTerminal() and pane.mode == .tty); try std.testing.expect(pane.v9fs_on_spawn); try std.testing.expect(!pane.greet); - try std.testing.expect(!p.takesCommandLine(p.active)); + try std.testing.expect(!exec.takesCommandLine(p, p.active)); panes.Terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07"); - try std.testing.expect(p.takesCommandLine(p.active)); + try std.testing.expect(exec.takesCommandLine(p, p.active)); panes.Terminal.feedOutput(p, pane, "\x1b]133;C\x07"); - try std.testing.expect(!p.takesCommandLine(p.active)); + try std.testing.expect(!exec.takesCommandLine(p, p.active)); } test "first document fallback splits like Tty from the originating pane" { @@ -1215,8 +1193,8 @@ test "first document fallback splits like Tty from the originating pane" { p.active = (from + 1) % 3; const free = p.freeSlot().?; const doc = try p.openTutorView(free); - p.placeDoc(from, free, doc); - tty.spawnTty(from); + exec.placeDoc(p, from, free, doc); + exec.spawnTty(tty, from); p.sync(); tty.sync(); try std.testing.expectEqual(@as(usize, 2), p.ncol); @@ -1240,7 +1218,7 @@ test "first document keeps its originating shell and only opens a column when it defer p.deinit(); const shell = p.panes[0].?; const doc = try p.openTutorView(1); - p.placeDoc(0, 1, doc); + exec.placeDoc(p, 0, 1, doc); p.sync(); try std.testing.expectEqual(shell, p.panes[0].?); try std.testing.expectEqual(@as(usize, 1), p.active); @@ -1318,7 +1296,7 @@ test "unknown Exec from an image writes to a terminal in the image directory" { const image_doc = try p.newDocPane(1); image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") }; try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_doc)); - try std.testing.expectEqual(@as(?usize, 0), p.execute(1, "echo image-fallback")); + try std.testing.expectEqual(@as(?usize, 0), exec.execute(p, 1, "echo image-fallback")); var sent: [256]u8 = undefined; var sent_len: usize = 0; @@ -1364,7 +1342,7 @@ test "Exec in a terminal whose tty is taken spawns a shell instead of typing at var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; - const dst = p.execute(0, "echo taken-fallback") orelse return error.ExecFoundNowhereToRun; + const dst = exec.execute(p, 0, "echo taken-fallback") orelse return error.ExecFoundNowhereToRun; // somewhere ELSE — and still without moving focus, which is execute's // contract and the whole difference between it and a look try std.testing.expect(dst != 0); @@ -1466,7 +1444,7 @@ test "Exec from a document pane skips an occupied terminal in its directory and host.install(p); host.taken[0] = true; - const dst = p.execute(1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun; + const dst = exec.execute(p, 1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun; try std.testing.expect(dst != 0 and dst != 1); var spawned: ?Effect = null; @@ -1551,13 +1529,13 @@ test "a terminal its program gave back takes command lines again" { var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; - try std.testing.expect(p.execute(0, "echo while-taken") != 0); + try std.testing.expect(exec.execute(p, 0, "echo while-taken") != 0); while (p.nextEffect()) |_| {} // the program exited, so the host's answer changes — and the core asks // again, because it never cached the old one host.taken[0] = false; - try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo after")); + try std.testing.expectEqual(@as(?usize, 0), exec.execute(p, 0, "echo after")); var sent: [64]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { @@ -1595,13 +1573,13 @@ test "the host is asked about a tty only where a command line is about to go" { try std.testing.expectEqual(@as(usize, 0), host.asked); // The pane an Exec is typed into is one question, asked once... - _ = p.execute(0, "echo lazy"); + _ = exec.execute(p, 0, "echo lazy"); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 1), host.asked); host.taken[0] = true; host.asked = 0; - _ = p.execute(0, "echo lazy-again"); + _ = exec.execute(p, 0, "echo lazy-again"); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 2), host.asked); } @@ -1654,7 +1632,7 @@ test "owned cwd preserves long paths aliases and failed updates" { const free = p.freeSlot().?; try std.testing.expectError(error.PathTooLong, p.newShell(free, path[0 .. effect_path_cap + 1])); try std.testing.expect(p.panes[free] == null); - p.saveTo(0, "file.txt"); + exec.saveTo(p, 0, "file.txt"); try std.testing.expectEqual(effects, p.effects_len); try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PathTooLong") != null); @@ -1820,7 +1798,7 @@ test "owned cwd Save promotion releases the former directory" { const id = p.active; const pane = p.panes[id].?; try pane.setOwnedCwd("/old/directory"); - p.saveTo(id, "saved.txt"); + exec.saveTo(p, id, "saved.txt"); try std.testing.expect(pane.cwd == .none); try std.testing.expect(pane.file.?.output == null); try std.testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path); @@ -1847,7 +1825,7 @@ test "Save on a scratch asks for a path in its inherited dir and makes it a file // typing the filename and submitting converts it into an ordinary file try std.testing.expect(np.appendTag("note.txt")); - p.submitSave(id); + exec.submitSave(p, id); try std.testing.expect(np.file.?.output == null); try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path); var saved = false; @@ -1872,7 +1850,7 @@ test "Save on a terminal writes its plaintext scrollback and stays a terminal" { try std.testing.expect(p.executeBuiltinLine(0, "Save")); try std.testing.expect(pane.prompt == .save); try std.testing.expect(pane.appendTag("log.txt")); - p.submitSave(0); + exec.submitSave(p, 0); // the pane is untouched: still a terminal, no file behind it try std.testing.expect(pane.isTerminal()); @@ -1921,7 +1899,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.prompt == .save); try std.testing.expect(out.appendTag("help.txt")); - p.submitSave(id); + exec.submitSave(p, id); // the buffer itself is untouched: same origin, same name, still refillable // and still walked by n/N, because a saved copy of a list is only a copy @@ -2156,7 +2134,7 @@ test "a save path that names no file is refused before anything is rewritten" { try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(scratch.prompt == .save); - p.submitSave(id); + exec.submitSave(p, id); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); try std.testing.expect(scratch.file.?.output != null); @@ -2213,7 +2191,7 @@ test "a builtin that needs a pane reports capacity failure when every slot is fu _ = try p.newDocPane(id); }; const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin; - p.runBuiltin(help, 0, "", null); + exec.runBuiltin(p, help, 0, "", null); try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: NoPaneSlots") != null); } @@ -2760,7 +2738,7 @@ test "column tags follow their column through split and close" { defer p.deinit(); _ = try p.setTestFile("left\n"); try std.testing.expect(p.col_tags[0].set("Left Find")); - _ = p.execute(0, "Newcol"); + _ = exec.execute(p, 0, "Newcol"); try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqualStrings("Left Find", p.columnTag(0)); try std.testing.expect(p.col_tags[1].set("Right Grep")); @@ -3362,7 +3340,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" { const moving = try p.render(frame.allocator()); p.acknowledgePanelPresentation(moving.panelTracks()); try std.testing.expect(moving.panelTracks().len > 0); - p.applySettingBuiltin(config.Runtime.find("PanelZoom").?, null); + exec.applySettingBuiltin(p, config.Runtime.find("PanelZoom").?, null); try std.testing.expectEqual(layout.Transition.zoom, p.settings.panel_transition); try std.testing.expect(p.presentation.pending); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); @@ -3379,11 +3357,11 @@ test "canonical fallback and transition toggle retire unpresented tracks" { const old_tagline_percent = p.settings.font.tagline_percent; var percent_buf: [3]u8 = undefined; const same_percent = try std.fmt.bufPrint(&percent_buf, "{d}", .{old_tagline_percent}); - p.applySettingBuiltin(config.Runtime.find("TaglineSize").?, same_percent); + exec.applySettingBuiltin(p, config.Runtime.find("TaglineSize").?, same_percent); try std.testing.expect(p.animationActive()); const changed_percent: u8 = if (old_tagline_percent == 73) 74 else 73; const changed_text = try std.fmt.bufPrint(&percent_buf, "{d}", .{changed_percent}); - p.applySettingBuiltin(config.Runtime.find("TaglineSize").?, changed_text); + exec.applySettingBuiltin(p, config.Runtime.find("TaglineSize").?, changed_text); try std.testing.expectEqual(changed_percent, p.settings.font.tagline_percent); try std.testing.expect(p.presentation.pending); for (p.presentation.tracks) |track| try std.testing.expect(track == null); @@ -3839,6 +3817,10 @@ pub const Pardes = struct { pub const setMessage = Messages.setMessage; pub const reportError = Messages.reportError; + /// Running a builtin line, called from everywhere as + /// `p.executeBuiltinLine(...)`; it lives with the rest of exec in exec.zig. + pub const executeBuiltinLine = exec.executeBuiltinLine; + pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; const pdf_gpa = opts.pdf_allocator orelse gpa; @@ -3936,7 +3918,7 @@ pub const Pardes = struct { }, } p.sync(); - p.applyStartupConfig(); + exec.applyStartupConfig(p); colors.finishThemeInitialization(p); p.sync(); p.presentation.enabled = true; @@ -4272,16 +4254,6 @@ pub const Pardes = struct { if (look.lookHoverPane(p) == id) look.cancelLookHover(p); } - pub fn takesCommandLine(p: *const Pardes, id: usize) bool { - const pane = p.panes[id] orelse return false; - if (!pane.isTerminal()) return false; - // A mounted shell sits behind sudo's process supervisor. Its OSC 133 - // prompt marks identify input readiness; the launcher's executable is - // not the shell executable used by the ordinary process heuristic. - if (pane.v9fs_on_spawn) return panes.Terminal.promptInputReady(pane); - return !p.hostTtyTaken(id); - } - pub fn shellBin(p: *const Pardes) []const u8 { const selected = p.settings.shell.requested.get(); return if (selected.len == 0) config.default_shell else selected; @@ -4631,7 +4603,7 @@ pub const Pardes = struct { } pub fn update(p: *Pardes, ev: Event) void { - defer p.replaceStillborn(); + defer exec.replaceStillborn(p); // A tick with nothing animating is the one event that cannot change // the screen. p.needs_frame = p.needs_frame or ev != .tick; @@ -4805,7 +4777,7 @@ pub const Pardes = struct { return defaultTail(pane); } - fn defaultTail(pane: *const Pane) []const u8 { + pub fn defaultTail(pane: *const Pane) []const u8 { if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail; if (pane.file) |file| if (file.output) |out| if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail; @@ -5029,7 +5001,7 @@ pub const Pardes = struct { } if (p.tagChordText(pane)) |txt| { pane.tag_sel = false; - p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", txt); + exec.runBuiltin(p, if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", txt); } return; } @@ -5086,7 +5058,7 @@ pub const Pardes = struct { const body_mode = pane.tag_mode; exitTagEdit(pane); // leave the tag FIRST: Del frees the pane pane.mode = body_mode; - p.runBuiltin(cmd, id, "", txt); + exec.runBuiltin(p, cmd, id, "", txt); return; } if (hit(key, config.tag_yank)) { @@ -5116,7 +5088,7 @@ pub const Pardes = struct { } else null; if (dir) |d| { const from = p.active; - p.runBuiltin(d, from, "", null); + exec.runBuiltin(p, d, from, "", null); if (p.active == from) { if (d == .Up) { exitTagEdit(pane); @@ -5208,8 +5180,8 @@ pub const Pardes = struct { if (word.len > 0) { if (!p.header_insert) { // Preserve the workspace tag's keyboard command semantics. - _ = p.execute(p.active, word); - } else p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", word); + _ = exec.execute(p, p.active, word); + } else exec.runBuiltin(p, if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", word); } return; } @@ -5295,185 +5267,6 @@ pub const Pardes = struct { if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len))); } - fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool { - if (pane.nsel == 0) return false; - const text = edit.flatSurface(p, pane) catch return false; - var ranges: [Pane.max_selections]modal.Selection = undefined; - const got = pane.ranges(text, 0, &ranges); - if (got.n < 2) return false; - - var texts: [Pane.max_selections][]u8 = undefined; - var made: usize = 0; - defer for (texts[0..made]) |t| p.gpa.free(t); - for (ranges[0..got.n]) |range| { - const lo = @min(range.anchor, range.head); - const hi = @max(range.anchor, range.head); - if (hi > text.len) break; - texts[made] = p.gpa.dupe(u8, text[lo..hi]) catch break; - made += 1; - } - // All or nothing, like the pipe: half a chord is not a chord. - if (made != got.n) return false; - - const id = p.active; - const serial = pane.serial; - pane.vsel.active = false; - pane.msel.active = false; - pane.select = false; - pane.nsel = 0; // the chord consumed them, exactly as it consumes one - - var primary_active: ?usize = null; - for (texts[0..made], 0..) |txt, i| { - p.runBuiltin(cmd, id, "", txt); - if (i == got.pri) primary_active = p.active; - const still = p.panes[id] orelse break; - if (still.serial != serial) break; - } - if (primary_active) |a| if (p.panes[a] != null) { - p.active = a; - }; - return true; - } - - pub const PointerOperand = struct { - /// Absolute body position corresponding to the pointed screen cell. - row: i32, - col: i32, - /// Exactly what a no-drag middle/right click will dispatch. - text: ?[]const u8 = null, - /// What hover paints. Null names the pane's live modal selection. - preview: ?Pane.Sel = null, - /// File words stay logical so a soft-wrapped operand is not cut into - /// unrelated rendered fragments. Null for tags and non-file panes. - file_word: ?look.FileWordSpan = null, - /// A newly expanded word is installed in the gesture's button slot; - /// an existing selection is only borrowed and must not replace it. - expanded: ?Pane.Sel = null, - }; - - pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand { - const visible = clicked.r0 - @as(i32, BOX_H); - const wrapped = pane.wrapAt(visible); - const raw = pane.isTerminal() and pane.mode == .tty and clicked.r0 >= BOX_H; - const row = if (raw) panes.Terminal.gridOffset(pane) + visible else wrapped.line; - const raw_line = if (raw) modal.lineSlice(body_layer.bodyText(p, p.scratch.allocator(), pane) catch "", @intCast(@max(0, visible))) else ""; - const col = if (raw) - @as(i32, @intCast(panes.File.rawAtDisplay(raw_line, @intCast(@max(0, clicked.c0))))) - else if (clicked.r0 >= BOX_H) - edit.paneByteAtDisplay( - p, - pane, - wrapped.line, - wrapped.at, - clicked.c0 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), - ) - else - clicked.c0; - var result: PointerOperand = .{ .row = row, .col = col }; - - const kept = pane.sel[sel_slot]; - const inside_kept = if (pane.pointerSelection(kept)) |rows| inside: { - if (clicked.r0 < BOX_H) break :inside false; - if (pane.file != null and clicked.c0 < panes.File.gutterWidth(pane)) break :inside false; - const source_col: usize = @intCast(@max(0, col)); - for (rows) |span| { - const target = if (span.raw_terminal) - (if (raw) span.row else pane.surfRow(span.row)) - else - (if (raw) pane.gridRow(span.row) else span.row); - var lo = span.lo; - var hi = span.hi; - if (span.raw_terminal and !raw) { - lo -|= span.prompt_bytes; - hi -|= span.prompt_bytes; - } else if (!span.raw_terminal and raw) { - const prefix = panes.Terminal.promptPrefixBytes(pane, row, raw_line); - lo += prefix; - hi += prefix; - } - if (target == row and source_col >= lo and source_col < hi) - break :inside true; - } - break :inside false; - } else edit.spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1); - if (kept.state == .done and inside_kept) { - result.text = edit.selectionText(p, pane, kept) catch null; - result.preview = kept; - return result; - } - if (pane.vsel.active and pane.vsel.explicit and - edit.spanHas(row, col, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col)) - { - result.text = edit.currentSelText(p, pane); - return result; - } - if (pane.msel.active and row >= @min(pane.msel.r0, pane.msel.r1) and - row <= @max(pane.msel.r0, pane.msel.r1)) - { - result.text = edit.currentSelText(p, pane); - return result; - } - if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { - const line = panes.File.sourceLine(pane, row); - const source_col: usize = @intCast(@max(0, col)); - const b = look.expandedWord(line, source_col) orelse return result; - const lo = std.math.cast(i32, b.lo) orelse return result; - const hi = std.math.cast(i32, b.hi) orelse return result; - result.text = line[b.lo..b.hi]; - result.file_word = .{ .row = row, .lo = lo, .hi = hi }; - - var expanded = clicked; - const first = pane.wrapRow(row, lo); - const last = pane.wrapRow(row, hi - 1); - if (first.row >= 0 and last.row >= 0) { - expanded.r0 = first.row + @as(i32, BOX_H); - expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, lo); - expanded.r1 = last.row + @as(i32, BOX_H); - expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1); - } - result.preview = expanded; - result.expanded = expanded; - return result; - } - - const expanded = look.expandedSel(p, pane, clicked) orelse return result; - result.text = edit.selectionText(p, pane, expanded) catch null; - if (result.text == null or result.text.?.len == 0) return result; - result.preview = expanded; - result.expanded = expanded; - return result; - } - - pub 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 (comptime pdf_enabled) if (p.native_images) if (t.pdf) |pv| { - if (pv.selection != null and pv.selection_text.len > 0) - return pv.selection_text; - }; - if (t.sel[sel_slot].state == .done) { - if (edit.selectionText(p, t, t.sel[sel_slot]) catch null) |tx| return tx; - } else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) { - if (edit.currentSelText(p, t)) |tx| return tx; - } - } - return null; - } - - pub 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; - } - /// Cycle only the body modes supported by this pane. pub fn cycleMode(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; @@ -5516,8 +5309,8 @@ pub const Pardes = struct { const asked = slot orelse continue; if (asked.prompt != .del_side) continue; asked.prompt = .none; - if (hit(key, config.del_above)) p.runBuiltin(.Del, id, "", "k"); - if (hit(key, config.del_below)) p.runBuiltin(.Del, id, "", "j"); + if (hit(key, config.del_above)) exec.runBuiltin(p, .Del, id, "", "k"); + if (hit(key, config.del_below)) exec.runBuiltin(p, .Del, id, "", "j"); return; } if (p.topbar_col != null) return p.topbarKey(key); @@ -5530,9 +5323,9 @@ pub const Pardes = struct { // Shift-Escape is the unconditional way out, even mid-application; // bare Escape only leaves at a shell prompt. if (hit(key, config.tty_toggle_alt)) - return p.runBuiltin(.Last, p.active, "", null); - if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and p.takesCommandLine(p.active)) - return p.runBuiltin(.Last, p.active, "", null); + return exec.runBuiltin(p, .Last, p.active, "", null); + if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and exec.takesCommandLine(p, p.active)) + return exec.runBuiltin(p, .Last, p.active, "", null); panes.Terminal.followOutput(pane); // typing snaps back to live output // Paste stays the window's, the one exception to forwarding a raw // tty's keys: Ctrl-V types the register at the program and @@ -5554,7 +5347,7 @@ pub const Pardes = struct { if (p.ctrl_w_pending) { p.ctrl_w_pending = false; for (config.window_keys) |wk| { - if (hit(key, &.{ wk.letter, wk.arrow })) return p.runBuiltin(wk.cmd, p.active, "", null); + if (hit(key, &.{ wk.letter, wk.arrow })) return exec.runBuiltin(p, wk.cmd, p.active, "", null); } return; } @@ -5579,7 +5372,7 @@ pub const Pardes = struct { return; } for (config.jump_keys) |jk| { - if (hit(key, &.{jk.chord})) return p.runBuiltin(jk.cmd, p.active, "", null); + if (hit(key, &.{jk.chord})) return exec.runBuiltin(p, jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { const f = layout.findPane(p, p.active).?; @@ -5601,7 +5394,7 @@ pub const Pardes = struct { pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len); }, .save => |at| { - if (submit) p.submitSave(p.active) else pane.tag_tail_len = @min(at, pane.tag_tail_len); + if (submit) exec.submitSave(p, p.active) else pane.tag_tail_len = @min(at, pane.tag_tail_len); }, .search => |at| { if (submit) @@ -5629,18 +5422,18 @@ pub const Pardes = struct { const explicit = (p.native_images and pane.hasPdfSelection()) or (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { - if (p.chordEachSel(pane, cmd)) return; + if (exec.chordEachSel(p, pane, cmd)) return; if (edit.currentSelText(p, pane)) |txt| { pane.vsel.active = false; pane.msel.active = false; pane.select = false; - p.runBuiltin(cmd, p.active, "", txt); + exec.runBuiltin(p, cmd, p.active, "", txt); return; } } const sel = look.expandedSel(p, pane, look.cursorWordSel(p, pane)) orelse return; const word = edit.selectionText(p, pane, sel) catch return; - p.runBuiltin(cmd, p.active, "", word); + exec.runBuiltin(p, cmd, p.active, "", word); return; } if (pane.mode == .normal and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key); @@ -5672,7 +5465,7 @@ pub const Pardes = struct { // a path-less builtin is a row here too (it is the same index Help // reads), and SPC cannot reach one — so both scans skip it for (builtin_rows) |row| { - if (std.mem.eql(u8, row.path orelse continue, pfx)) return p.runBuiltin(row.cmd, p.active, "", null); + if (std.mem.eql(u8, row.path orelse continue, pfx)) return exec.runBuiltin(p, row.cmd, p.active, "", null); } for (builtin_rows) |row| { if (std.mem.startsWith(u8, row.path orelse continue, pfx)) { @@ -5744,73 +5537,7 @@ pub const Pardes = struct { pane.tag_col = @intCast(prefix_len + pane.tag_tail_len); } - /// Enter on a save input: the path is everything past the marker. - fn submitSave(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - const at = switch (pane.prompt) { - .save => |a| a, - else => return, - }; - const armed = pane.tag_tail[at..pane.tag_tail_len]; - pane.tag_tail_len = at; // drop the prompt back to the seeded tail - if (!std.mem.startsWith(u8, armed, config.save_marker)) return; - const path = std.mem.trim(u8, armed[config.save_marker.len..], " \t\r\n"); - if (path.len == 0) return; - p.saveTo(id, path); - } - - /// Write the file this pane already has: acme's Put, and the one Save that - /// needs no argument because the path was never a question. - pub fn saveFile(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - const f = if (pane.file) |*file| file else return; - if (f.output != null) return; // nothing behind it yet: saveTo, with a path - p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); - } - - pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void { - const pane = p.panes[id] orelse return; - if (path.len == 0 or path[path.len - 1] == '/' or - std.mem.indexOfScalar(u8, path, '\n') != null) - return p.reportError(id, "save", error.NoFilename); - const full = std.fs.path.resolvePosix( - p.scratch.allocator(), - &.{ paneDir(pane), path }, - ) catch return; - if (!std.fs.path.isAbsolute(full)) return p.reportError(id, "save", error.NoDirectory); - if (full.len > SavePath.cap) return p.reportError(id, "save", error.PathTooLong); - const f = if (pane.file) |*file| file else { - // a terminal writes its scrollback; an image and a PDF have nothing - // of their own that is unwritten, so they have nothing to write - if (pane.isTerminal()) p.askWrite(id, pane.serial, full); - return; - }; - if (f.output != null and panes.Output.fileTraits(f.output).saves) { - const owned = p.gpa.dupe(u8, full) catch return; - p.gpa.free(f.path); - f.path = owned; - f.output = null; // an ordinary file pane from here on - f.watch_after_save = true; - pane.clearCwd(); - pane.tag_init = false; // re-derive the tag as a plain file - pane.tag_tail_len = 0; - p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); - return; - } - // its own path, spelled out: the in-place write, so the pane comes clean - if (f.output == null and std.mem.eql(u8, f.path, full)) return p.saveFile(id); - p.askWrite(id, pane.serial, full); - } - - const SavePath = Effect.Buf(effect_path_cap); - - fn askWrite(p: *Pardes, id: usize, serial: u32, path: []const u8) void { - p.emit(.{ .save_text = .{ - .pane = @intCast(id), - .serial = serial, - .path = SavePath.from(path), - } }); - } + pub const SavePath = Effect.Buf(effect_path_cap); pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { if (!normal.multiOnce(p)) return; // one question per keystroke, from the primary @@ -6054,104 +5781,6 @@ pub const Pardes = struct { p.settings.scene_effects = .{}; } - /// Tty: a shell in the caller's directory, raw from the first frame, - /// stacked below the caller like Alt-n's new shell. - pub fn spawnTty(p: *Pardes, from: usize) void { - p.spawnTtyWithMount(from, false); - } - - pub fn spawnV9fsTty(p: *Pardes, from: usize) void { - if (comptime !hosted or @import("builtin").os.tag != .linux) - return p.reportError(from, "Tty9p", error.LinuxRequired); - if (p.fs.socket_path.len == 0) - return p.reportError(from, "Tty9p", error.SessionSocketRequired); - p.spawnTtyWithMount(from, true); - } - - fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) void { - const src = p.panes[from] orelse return; - const free = p.freeSlot() orelse return; - const nt = p.newShell(free, paneDir(src)) catch return; - nt.greet = !v9fs; - nt.v9fs_on_spawn = v9fs; - nt.mode = .tty; - const parent = layout.splitParent(p, from); - const f = layout.findPane(p, parent).?; - layout.insert(p, f.col, f.idx + 1, free); - layout.splitBelow(p, parent, nt); - p.active = free; - p.stillborn_joiner = nt.serial; - } - - /// when a doc lands in `col`, a lone pristine shell there is clutter — drop - /// it; absorbVWeight hands its space to the doc - pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { - var n_tty: usize = 0; - var tty_id: usize = 0; - for (0..p.col_n[col]) |k| { - const cid = p.col_panes[col][k]; - if (p.panes[cid]) |ct| if (ct.isTerminal()) { - n_tty += 1; - tty_id = cid; - }; - } - if (n_tty != 1) return; - const tt = p.panes[tty_id] orelse return; - // No typing, cursor on the first prompt line, no scrollback: this is - // the throwaway boot placeholder a document may replace. - if (tt.ovl != null or panes.Terminal.gridCursor(tt).y != 0 or - panes.Terminal.scrollbar(tt).total > tt.rows) return; - p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err); - layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows - layout.absorbVWeight(p, tty_id, null); - layout.removePane(p, tty_id); - p.panes[tty_id] = null; - if (p.active == tty_id) p.active = keep_id; - } - - /// A pane that just joined a column whose only other pane is stillborn — - /// an empty scratch nobody typed into, body or tag (what Newcol makes and - /// what a closed column's last pane leaves behind) — takes the column - /// whole: the placeholder was only holding the space for it. Run at the - /// end of a step: the pane that asked for the joiner is often the - /// placeholder itself, and its caller is still holding it. - fn replaceStillborn(p: *Pardes) void { - const serial = p.stillborn_joiner orelse return; - p.stillborn_joiner = null; - const new_id = p.paneBySerial(serial) orelse return; - const f = layout.findPane(p, new_id) orelse return; - if (p.col_n[f.col] != 2) return; - const old_id = p.col_panes[f.col][1 - f.idx]; - const old = p.panes[old_id] orelse return; - const file = old.file orelse return; - const out = file.output orelse return; - if (out.from != .cmd or out.from.cmd != .New) return; - if (file.revision != 0 or file.content.len != 0) return; - if (old.tag_init and !std.mem.eql(u8, old.tagSlice(), defaultTail(old))) return; - const was_active = p.active == old_id; - layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows - p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err); - if (was_active) p.active = new_id; - } - - fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { - for (p.panes, 0..) |slot, i| if (slot) |tt| { - if (!std.mem.eql(u8, tt.cwdSlice(), dir)) continue; - if (p.takesCommandLine(i)) return i; - }; - const free = p.freeSlot() orelse return null; - const nt = p.newShell(free, dir) catch return null; - nt.greet = false; - const rc = if (p.ncol > 0) p.ncol - 1 else 0; - if (p.col_n[rc] > 0) { - const src = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); - const f = layout.findPane(p, src).?; - layout.splitBelow(p, src, nt); - layout.insert(p, f.col, f.idx + 1, free); - } else layout.insert(p, rc, p.col_n[rc], free); - return free; - } - // ---- doc panes ---- pub fn openTutorView(p: *Pardes, id: usize) !*Pane { @@ -6188,172 +5817,6 @@ pub const Pardes = struct { // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- - const max_exec_depth = 8; - - pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { - const pane = p.panes[id] orelse return null; - const cmd = commandText(txt); - if (cmd.len == 0) return null; - if (p.exec_depth == 0) p.noteHaptic(.exec); - if (p.executeBuiltinLine(id, cmd)) return null; - if (p.exec_depth >= max_exec_depth) return null; - p.exec_depth += 1; - defer p.exec_depth -= 1; - const dst = (if (p.takesCommandLine(id)) id else p.ttyForDir(paneDir(pane))) orelse return null; - panes.Terminal.padOutputBelowEdits(p, dst); - if (panes.Terminal.queuePendingCommand(p.panes[dst].?, cmd) catch |err| { - p.reportError(id, "queue command", err); - return dst; - }) return dst; - p.emitWrite(dst, cmd); - p.emitWrite(dst, "\r"); - return dst; - } - - /// Trim and unwrap clickable command-word notation once for every command - /// consumer, so startup lines and interactive Exec speak the same syntax. - pub fn commandText(txt: []const u8) []const u8 { - var cmd = std.mem.trim(u8, txt, " \t\r\n"); - while (config.commandWord(cmd)) |inner| cmd = std.mem.trim(u8, inner, " \t\r\n"); - return cmd; - } - - pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { - const cmd = commandText(txt); - if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false; - 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 if (builtins.registry.takesArg(head)) head else null; - }; - const b = bi orelse return false; - p.exec_depth += 1; - defer p.exec_depth -= 1; - const typed = if (sp) |s| std.mem.trim(u8, cmd[s + 1 ..], " \t") else ""; - p.runBuiltin(b, id, cmd, if (typed.len > 0) typed else null); - return true; - } - - fn applyStartupConfig(p: *Pardes) void { - p.announce = false; - defer p.announce = true; - var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n'); - while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line); - } - - pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { - if (!normal.multiOnce(p)) return; // a builtin is per-keystroke, never per-cursor - const pane = p.panes[id] orelse return; - // A builtin says its own name before it runs, so a tagline click is - // legible even when what it did is invisible. Whatever the builtin - // then says for itself replaces the announcement. - if (p.announce and p.settings.verbose and builtins.registry.announces(b)) { - p.setMessage(id, @tagName(b)); - pane.msg_announcement = true; - } - const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; - builtins.registry.dispatch(b, c); - } - - pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void { - const previous_colors = p.settings.colors; - const previous_context_style = p.settings.tree_context_tag_style; - const previous_transition = p.settings.panel_transition; - const previous_tagline_percent = p.settings.font.tagline_percent; - const previous_column_tags = p.settings.column_tags; - switch (setting.action) { - .theme => { - const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); - for (themes, 0..) |t, i| if (std.mem.eql(u8, t.name, want)) { - colors.setThemeIndex(p, i); - return; - }; - }, - .font => { - if (comptime !font_picker) return; - const spec = config.Runtime.FontSpec.parse(arg orelse return) orelse return; - const matches = fonts.list(p.scratch.allocator(), spec.name); - if (matches.len == 0) return; - if (!p.settings.requestFont(matches[0].path, matches[0].name, spec.size_hundredths)) return; - p.font_request_taken = false; - }, - else => _ = p.settings.apply(setting, arg), - } - if (p.settings.colors != previous_colors) { - for (p.panes) |slot| { - const pane = slot orelse continue; - if (pane.file) |*file| file.syntax_dirty = true; - } - } - if (p.settings.tree_context_tag_style != previous_context_style) { - for (p.panes, 0..) |slot, id| { - p.surface.body_layers[id].rows = 0; - const pane = slot orelse continue; - pane.body_rows = 0; - pane.body_visible_rows = 0; - pane.context_row_limit = null; - pane.wrap_n = 0; - } - p.abandonPanelAnimations(); - } - const transition_changed = setting.action == .transition and - p.settings.panel_transition != previous_transition; - const tagline_metrics_changed = setting.action == .tagline_size and - p.settings.font.tagline_percent != previous_tagline_percent; - if (p.settings.column_tags != previous_column_tags) { - if (p.header_column != null) p.exitHeader(); - } - if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) { - p.abandonPanelAnimations(); - } - } - - pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { - p.stillborn_joiner = nt.serial; - const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc - var src_id: ?usize = null; - if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) { - src_id = from_id; - }; - if (doc and src_id == null) { - var n = p.njumps; - while (n > 0) : (n -= 1) { - const j = p.jumps[n - 1]; - const pp = p.panes[j.pane] orelse continue; - if (pp.serial != j.serial) continue; - if (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf()) { - src_id = j.pane; - break; - } - } - } - if (doc and src_id == null) for (p.panes, 0..) |sl, i| { - if (sl) |pp| if (i != free and (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf())) { - src_id = i; - break; - }; - }; - if (!doc) if (layout.findPane(p, from_id)) |sf| { - layout.insert(p, sf.col, sf.idx + 1, free); - layout.splitBelow(p, from_id, nt); // NOT splitParent: no bystander pays - p.active = free; - return; - }; - if (src_id == null and doc and p.ncol < MAX_COLS and - layout.columnFitsHalves(p, from_id, 100) and layout.splitColumn(p, from_id, free, true)) - { - p.active = free; - return; - } - // Without room for a first-document column, split the Look origin - // just like Tty. Never displace (or evict) a top-left bystander. - const src = layout.splitParent(p, src_id orelse from_id); - const sf = layout.findPane(p, src).?; - layout.insert(p, sf.col, sf.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; - } - // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- pub fn dumpState(p: *Pardes) !void { @@ -6562,7 +6025,7 @@ pub const Pardes = struct { } p.active = @min(st.active, MAX_PANES - 1); p.sync(); - p.applyStartupConfig(); + exec.applyStartupConfig(p); colors.finishThemeInitialization(p); p.sync(); p.presentation.enabled = true; @@ -7725,7 +7188,7 @@ test "Esc alternates between two panes of the SAME kind" { defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 40 } }); const a = p.active; - p.runBuiltin(.Look, a, "", "build.zig"); + exec.runBuiltin(p, .Look, a, "", "build.zig"); p.sync(); const b = p.active; try std.testing.expect(b != a); @@ -8229,7 +7692,7 @@ test "raw tty Escape only leaves at a shell prompt" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); - p.spawnTty(0); + exec.spawnTty(p, 0); const shell = p.active; const pane = p.panes[shell].?; var buf: [256]u8 = undefined; @@ -8260,7 +7723,7 @@ test "raw tty Ctrl-B switches to editor mode without sending child input" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .file = "mise.toml" }); defer p.deinit(); - p.spawnTty(0); + exec.spawnTty(p, 0); const shell = p.active; p.sync(); var buf: [256]u8 = undefined; @@ -8313,7 +7776,7 @@ test "hopping between two panes does not grow the jump stack" { try std.testing.expectEqual(depth, p.njumps); const before = p.active; - p.runBuiltin(.Back, p.active, "", null); + exec.runBuiltin(p, .Back, p.active, "", null); p.sync(); try std.testing.expect(p.active != before); try std.testing.expectEqual(depth, p.njumps); diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 9b623183..721dac5a 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -3,6 +3,7 @@ const panes = @import("panes.zig"); const std = @import("std"); const pardes = @import("pardes.zig"); +const exec = @import("exec.zig"); const layout = @import("layout.zig"); const config = @import("config.zig"); const modal = @import("modal.zig"); @@ -2044,7 +2045,7 @@ pub fn openSections(core: *pardes.Pardes, id: usize) void { .serial = result.serial, .revision = result.file.?.revision, }; - core.placeDoc(id, free, result); + exec.placeDoc(core, id, free, result); layout.compute(core); core.active = id; pane.search_pane = free; diff --git a/test/panes.zig b/test/panes.zig index f45d42b2..f99f9d56 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -721,7 +721,7 @@ const TtySelectionTests = struct { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 24 }); defer p.deinit(); p.presentation.enabled = false; - p.spawnTty(0); + pardes.exec.spawnTty(p, 0); for (p.panes[0..2]) |slot| { slot.?.mode = .tty; slot.?.tty_filter = false; @@ -3111,8 +3111,8 @@ const TerminalTests = struct { defer p.deinit(); while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet - try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first")); - try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second")); + try std.testing.expectEqual(@as(?usize, 0), pardes.exec.execute(p, 0, "echo first")); + try std.testing.expectEqual(@as(?usize, 0), pardes.exec.execute(p, 0, "echo second")); while (p.nextEffect()) |effect| switch (effect) { .write => return error.CommandEscapedBeforeFork, else => {}, diff --git a/test/pdf.zig b/test/pdf.zig index 69a82123..dde58df8 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -342,7 +342,7 @@ test "PdfSections Look follows the exact owning PDF, not an equal path" { const duplicate_id = p.freeSlot() orelse return error.NoDuplicatePdfSlot; const duplicate = try panes.Pdf.openPane(p, duplicate_id, path, 0); - p.placeDoc(0, duplicate_id, duplicate); + pardes.exec.placeDoc(p, 0, duplicate_id, duplicate); layout.compute(p); panes.Pdf.openSections(p, duplicate_id); const duplicate_output_id = duplicate.search_pane orelse return error.MissingDuplicatePdfSections; |
