From 52049344c1992850ea712e029eb5c4a88a7dfe19 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 22:30:58 -0300 Subject: Move exec out of pardes.zig into exec.zig Pure move, no behaviour change (acme keeps this in exec.c): execute, commandText, max_exec_depth, executeBuiltinLine, applyStartupConfig, runBuiltin and applySettingBuiltin; the getarg-style operand code (withArg, PointerOperand, pointerOperand, heldSelection, chordEachSel); takesCommandLine and the terminals commands run in (spawnTty, spawnV9fsTty, spawnTtyWithMount, evictLonePristineTty, replaceStillborn, ttyForDir); placeDoc; and the save path (submitSave, saveFile, saveTo, askWrite), with the acme-chords test, go verbatim to exec.zig. The methods become free functions taking `p: *Pardes`. executeBuiltinLine is called from ~170 places as `p.executeBuiltinLine(..)`, so Pardes keeps one declaration alias for it and those call sites stay; the other 104 calls change from `p.execute(..)` to `exec.execute(p, ..)`. ninep/ctl.zig has a local named exec, so it writes `pardes.exec.execute` instead of importing the file. Co-Authored-By: Claude Opus 5.5 --- src/pardes.zig | 653 +++++---------------------------------------------------- 1 file changed, 58 insertions(+), 595 deletions(-) (limited to 'src/pardes.zig') 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); -- cgit v1.3