diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-27 22:30:58 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 52049344c1992850ea712e029eb5c4a88a7dfe19 (patch) | |
| tree | 24d12755e9545470dda8021542299f0f72fd0baf /src/exec.zig | |
| parent | 82551f77833a09b313bd3e58371582abd1aedcc4 (diff) | |
| download | pardes-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.zig | 571 |
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); +} |
