//! 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 tagline = @import("tagline.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; // A command pane's child is its command, never a shell to type at. if (!pane.isTerminal() or pane.command != null) 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.body.nsel == 0) return false; const text = edit.flatSurface(p, &pane.body) catch return false; var ranges: [panes.Text.max_selections]modal.Selection = undefined; const got = pane.body.ranges(text, 0, &ranges); if (got.n < 2) return false; var texts: [panes.Text.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.body.vsel.active = false; pane.body.msel.active = false; pane.body.select = false; pane.body.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, pane.tag_rows); const wrapped = pane.wrapAt(visible); const raw = pane.isTerminal() and pane.body.mode == .tty and clicked.r0 >= pane.tag_rows; 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 >= pane.tag_rows) edit.paneByteAtDisplay( p, &pane.body, 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 < pane.tag_rows) 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.body.vsel.active and pane.body.vsel.explicit and edit.spanHas(row, col, pane.body.vsel.row, pane.body.vsel.col, pane.body.cur_row, pane.body.cur_col)) { result.text = edit.currentSelText(p, pane); return result; } if (pane.body.msel.active and row >= @min(pane.body.msel.r0, pane.body.msel.r1) and row <= @max(pane.body.msel.r0, pane.body.msel.r1)) { result.text = edit.currentSelText(p, pane); return result; } if (pane.file != null and clicked.r0 >= pane.tag_rows 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, pane.tag_rows); expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, lo); expanded.r1 = last.row + @as(i32, pane.tag_rows); 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.body.vsel.active and t.body.vsel.explicit) or t.body.msel.active) { if (edit.currentSelText(p, t)) |tx| return tx; } } return null; } /// A clicked word and the selection held for it, as one command line. 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"); // A switch (Verbose, PanelSlide, Crt) clicked with some other text // selected flips, as it does bare: only `on` or `off` is an argument // for it. What a ctl write gives it is checked strictly instead. if (config.Runtime.find(commandText(t))) |setting| switch (setting.action) { .toggle, .transition, .scene => if (!std.mem.eql(u8, a, "on") and !std.mem.eql(u8, a, "off")) return txt, else => {}, }; 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; if (pane.prompt != .save) return; const armed = pane.promptText() orelse return; 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(); // re-derive the tag as a plain file's if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = null; 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.body.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.own) |own| if (!std.mem.eql(u8, own, tagline.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; } /// The longest line a command pane runs. pub const command_max = 1024; const max_exec_depth = 8; /// A word no builtin knows (acme's exec.c run()). Clicked in an interactive /// terminal, it is typed into that shell, whose state the click can see; /// from anywhere else it runs as its own command pane, a terminal whose /// child is `$SHELL -c` the line, which shows its output and `exit N` when /// done, and which Kill signals. Answers the pane it went to. 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; if (!takesCommandLine(p, id)) return runCommand(p, id, cmd); panes.Terminal.padOutputBelowEdits(p, id); panes.Terminal.noteCommand(pane, cmd); if (panes.Terminal.queuePendingCommand(pane, cmd) catch |err| { p.reportError(id, "queue command", err); return id; }) return id; p.emitWrite(id, cmd); p.emitWrite(id, "\r"); return id; } /// Runs `line` in a command pane for `from`'s directory: `from` itself if /// it is one whose command is done, else the directory's command pane that /// is done, else a new one below the last pane of the rightmost column. /// A pane reused keeps what it showed: the new command runs below a line /// naming it, as acme appends to +Errors and never clears it (util.c:213). fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { if (line.len > command_max) { p.reportFailure(from, "exec: a command line is at most 1024 bytes"); return null; } const src = p.panes[from] orelse return null; const dir = Pardes.paneDir(src); const reuse: ?usize = if (src.command != null and src.command_done) from else for (p.panes, 0..) |slot, i| { const other = slot orelse continue; if (other.command != null and other.command_done and std.mem.eql(u8, other.cwdSlice(), dir)) break i; } else null; if (reuse) |id| { const pane = p.panes[id].?; const owned = p.gpa.dupe(u8, line) catch |err| { p.reportError(from, "exec", err); return null; }; p.gpa.free(pane.command.?); pane.command = owned; pane.command_done = false; pane.command_status = null; pane.body.mode = .tty; // What the last program left the emulator in goes first: the // alternate screen left (only if it is there: leaving restores a // saved cursor), mouse reports and bracketed paste off, the cursor // shown, then a soft reset (DECSTR) for the rest. if (panes.Terminal.onAlternateScreen(pane)) panes.Terminal.feedOutput(p, pane, "\x1b[?1049l"); panes.Terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[!p"); var buf: [command_max + 8]u8 = undefined; const lead = if (panes.Terminal.gridCursor(pane).x != 0) "\r\n" else ""; panes.Terminal.feedOutput(p, pane, std.fmt.bufPrint(&buf, "{s}% {s}\r\n", .{ lead, line }) catch "%\r\n"); p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } }); noteRun(p, pane, "run", line); return id; } const free = p.freeSlot() orelse { p.reportFailure(from, "exec: no pane free for the command"); return null; }; const pane = p.newCommand(free, dir, line) catch |err| { p.reportError(from, "exec", err); return null; }; const rc = if (p.ncol > 0) p.ncol - 1 else 0; if (p.col_n[rc] > 0) { const parent = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); const f = layout.findPane(p, parent).?; layout.splitBelow(p, parent, pane); layout.insert(p, f.col, f.idx + 1, free); } else layout.insert(p, rc, p.col_n[rc], free); noteRun(p, pane, "run", line); return free; } /// `run ` or `exit ` in the log. fn noteRun(p: *Pardes, pane: *Pane, what: []const u8, said: []const u8) void { var buf: [96]u8 = undefined; const word = std.mem.trim(u8, said, " \t"); const first = word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len]; pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d} {s}", .{ pane.serial, first[0..@min(first.len, 64)] }) catch return); } /// A command pane's child is gone: the pane stays, showing what it said /// and how it ended, and is the next command's for its directory. pub fn commandDone(p: *Pardes, id: usize, status: ?u8) void { const pane = p.panes[id] orelse return; pane.command_done = true; pane.command_status = status; pane.body.mode = .normal; var buf: [16]u8 = undefined; const code = if (status) |n| std.fmt.bufPrint(&buf, "{d}", .{n}) catch "?" else "?"; var said: [32]u8 = undefined; const lead = if (panes.Terminal.gridCursor(pane).x != 0) "\r\n" else ""; panes.Terminal.feedOutput(p, pane, std.fmt.bufPrint(&said, "{s}exit {s}\r\n", .{ lead, code }) catch "exit ?\r\n"); noteRun(p, pane, "exit", code); p.needs_frame = true; } /// 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; // `Word arg`, or for a word that says so, `Word+arg` (`Tty+fish`). const plus = std.mem.indexOfScalar(u8, cmd, '+'); var sp = std.mem.indexOfAny(u8, cmd, " \t"); if (sp == null) if (plus) |at| if (std.meta.stringToEnum(Builtin, cmd[0..at])) |head| { if (builtins.registry.plusArg(head)) sp = at; }; 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; }; // A config line is not answered on screen (applyStartupConfig // runs with announce off); a click or a ctl write is. if (!p.announce) return; var near: [96]u8 = undefined; var text: [200]u8 = undefined; return p.reportFailure(p.active, std.fmt.bufPrint(&text, "Theme: no theme \"{s}\"; like it: {s}; ThemeSel lists all", .{ want[0..@min(want.len, 40)], colors.themesNear(&near, want) }) catch "Theme: no such theme"); }, .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) tagline.exitHeader(p); } 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.body.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); try std.testing.expectEqual(@as(u8, 1), pane.body.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].?.body.nsel); } test "a switch clicked with a selection held flips unless the selection says on or off" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); while (p.nextEffect()) |_| {} const before = p.settings.verbose; _ = execute(p, p.active, withArg(p, "Verbose", "some selected text")); try std.testing.expectEqual(!before, p.settings.verbose); _ = execute(p, p.active, withArg(p, "Verbose", "on")); try std.testing.expect(p.settings.verbose); _ = execute(p, p.active, withArg(p, "Verbose", " off\n")); try std.testing.expect(!p.settings.verbose); // A word that takes any argument still gets the selection. try std.testing.expectEqualStrings("Msg some text", withArg(p, "Msg", "some text")); }