summaryrefslogtreecommitdiff
path: root/src/exec.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 22:30:58 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit52049344c1992850ea712e029eb5c4a88a7dfe19 (patch)
tree24d12755e9545470dda8021542299f0f72fd0baf /src/exec.zig
parent82551f77833a09b313bd3e58371582abd1aedcc4 (diff)
downloadpardes-52049344c1992850ea712e029eb5c4a88a7dfe19.tar.gz
pardes-52049344c1992850ea712e029eb5c4a88a7dfe19.zip
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 <[email protected]>
Diffstat (limited to 'src/exec.zig')
-rw-r--r--src/exec.zig571
1 files changed, 571 insertions, 0 deletions
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);
+}