//! 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 grammars = @import("grammar_manifest.zig").all; const python_lang = blk: { @setEvalBranchQuota(20_000); break :blk @import("grammar_manifest.zig").byName("python").?; }; 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; // Someone's line at the shell's prompt, typed and not sent: typing a // command there would run it joined to theirs (`echo TYPEDls`). Only an // idle, EMPTY prompt is typed at; otherwise a new pane runs the line. // (Text in pardes's own edit buffer is not the shell's yet: an exec of // it, or of anything, types only what it runs.) if (panes.terminal.promptInputReady(pane) and !panes.terminal.promptInputEmpty(pane)) 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| { // Every selection is the body's, each one sent as the first is. runBuiltinFrom(p, cmd, id, "", txt, true); 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 { return operandAt(p, pane, clicked, true, true); } /// The body's explicit selection, else what a look at its cursor takes. pub fn currentOperand(p: *Pardes, pane: *Pane) ?[]const u8 { if (pane.file == null and !pane.isTerminal()) return null; if ((pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.msel.active) return edit.currentSelText(p, pane); return operandAt(p, pane, look.cursorWordSel(p, pane), false, true).text; } /// What a look or an exec at `clicked` takes: the ONE expansion every /// entry point uses (a B2/B3 click, Enter and Tab in normal mode, a bare /// Look), so a diff line, a hunk line's code word and a file word expand /// alike however they are reached (look.zig's "every entry point" test). /// `borrow`: a gesture inside a kept or modal selection takes it; the /// keyboard has said which selection it means already. `for_look`: only a /// look takes a diff line's address; an exec there takes the word, as /// anywhere else. pub fn operandAt(p: *Pardes, pane: *Pane, clicked: Pane.Sel, borrow: bool, for_look: bool) 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 }; if (borrow) { 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; } } // In a diff, a file or hunk header, and a hunk line pointed at on its // `+`/`-`/` ` prefix, expand to the whole line (the hover shows it) and // the look is of the address it names (look.diffAddress), a regular // look from there. A hunk line's code is words, looked at as ever: // the code's, the prefix never one of them. var word_from: usize = 0; if (for_look and clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1 and row >= 0) diff: { var address_buf: [4096 + 32]u8 = undefined; const at: usize = @intCast(row); const click = look.diffClick(p, pane, at, @intCast(@max(0, col)), &address_buf) orelse break :diff; const address = click.address orelse { word_from = click.word_from; break :diff; }; const line = (look.diffLines(p, pane) orelse break :diff)[at]; result.text = p.scratch.allocator().dupe(u8, address) catch break :diff; const hi = std.math.cast(i32, line.len) orelse return result; if (hi == 0) return result; var expanded = clicked; if (pane.file != null) { result.file_word = .{ .row = row, .lo = 0, .hi = hi }; const first = pane.wrapRow(row, 0); const last = pane.wrapRow(row, hi - 1); if (first.row < 0 or last.row < 0) return result; expanded.r0 = first.row + @as(i32, pane.tag_rows); expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, 0); 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); } else { const shown = if (raw) raw_line else line; expanded.c0 = 0; expanded.c1 = @as(i32, @intCast(panes.File.rawDisplayCol(shown, shown.len))) - 1; } result.preview = expanded; result.expanded = expanded; 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)); if (source_col < word_from or word_from > line.len) return result; const word = look.expandedWord(line[word_from..], source_col - word_from) orelse return result; const b: look.ExpandedWord = .{ .lo = word.lo + word_from, .hi = word.hi + word_from }; 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; } /// Where the selection `heldSelection` would give is, as acme's argument /// location (exec.c:162, getbytearg): `path:#q0,#q1` of a file pane's dot. pub fn heldSelectionPlace(p: *Pardes, first: usize, buf: []u8) ?[]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 (t.sel[sel_slot].state == .done) return null; // a pointer selection: no byte range kept if ((t.body.vsel.active and t.body.vsel.explicit) or t.body.msel.active) { const f = t.file orelse return null; const d = @import("ninep/pane.zig").dotOf(t); return std.fmt.bufPrint(buf, "{s}:#{d},#{d}", .{ f.path, d.q0, d.q1 }) catch null; } } 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 // Its file changed on disk under these edits: once, as acme's Put says // "modified since last read" (exec.c:577), and the next Save writes. if (f.disk_newer != null) { if (!f.disk_newer_warned) { f.disk_newer_warned = true; var said: [Pardes.SavePath.cap + 64]u8 = undefined; return p.reportFailure(id, std.fmt.bufPrint(&said, "{s} modified on disk since read (Save again to overwrite)", .{f.path}) catch "modified on disk since read (Save again to overwrite)"); } f.disk_newer = null; } 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); var home_buf: [4096]u8 = undefined; const full = std.fs.path.resolvePosix( p.scratch.allocator(), &.{ Pardes.paneDir(pane), @import("fs.zig").expandHome(path, &home_buf) }, ) 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; }; // A scratch takes the name once the write is done (promoteSaved): a // Save that fails changes nothing, the name included. if (f.output != null and panes.Output.fileTraits(f.output).saves) { p.emit(.{ .save_text = .{ .pane = @intCast(id), .serial = pane.serial, .path = Pardes.SavePath.from(full), .promote = true, } }); 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) ?*Pane { return 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) ?*Pane { // No pane asking (an empty window's tag): the session's directory, as // New's scratch is. // A directory that is not there makes no terminal: its shell could not // start in it, and a dead pane would answer no run. Asked only of a host // that starts shells (none, as in the core's tests, starts none). const dir = p.execDir(from); if (missingDir(p, dir)) { var said: [pardes.memory.limits.host_path_cap + 48]u8 = undefined; p.reportFailure(from, std.fmt.bufPrint(&said, "Tty: {s}: no such directory", .{dir}) catch "Tty: no such directory"); return null; } const free = p.freeSlot() orelse { p.reportError(from, "Tty", error.NoPaneSlots); return null; }; const nt = p.newShell(free, dir) catch |err| { if (err == error.PathTooLong) { p.reportFailure(from, std.fmt.comptimePrint("Tty: its directory is longer than a path may be ({d} bytes)", .{pardes.memory.limits.host_path_cap})); return null; } p.reportError(from, "Tty", err); return null; }; nt.greet = !v9fs; nt.v9fs_on_spawn = v9fs; nt.body.mode = .tty; placeNew(p, from, from, free, .shell); if (refusedPlacement(p, free)) { p.reportError(from, "Tty", error.NoPaneRoom); return null; } return nt; } /// A pane placeNew found no room for: taken back now, before anything is /// logged of it or answered with its serial (it was never announced), so /// no `new`/`del` pair and no serial a script was told of is left behind. fn refusedPlacement(p: *Pardes, id: usize) bool { if (!p.unplaced.isSet(id)) return false; p.unplaced.unset(id); p.removePane(id, null) catch {}; // As sync does for a refused pane: none it squeezed stays under its // minimum. for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) { _ = layout.shareColumn(p, c); }; return true; } /// Whether a shell would be started in `dir` and `dir` is not there: asked /// only of a host that starts shells, and only of a local path. pub fn missingDir(p: *Pardes, dir: []const u8) bool { if (comptime !hosted) return false; if (p.host.vtable.spawn == null) return false; // A peer's directory (`/n/peer/x`) is no directory on this host, where // the shell would start: no command or terminal runs there, and none // is made to say `exit 127`. const local = @import("fs.zig").localPath(dir) orelse return std.mem.startsWith(u8, dir, "/n/"); return !isDirectory(local); } /// Whether `path` is a directory on this host, the turn out while it asks /// (it may be inside a mount this editor serves). pub fn isDirectory(path: []const u8) bool { var buf: [4096]u8 = undefined; const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{path}, 0) catch return false; pardes.turn.yield(); defer pardes.turn.back(); const stat = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return false; return stat.kind == .directory; } /// Whether `path` is a plain file on this host, the turn out while it asks. pub fn isFile(path: []const u8) bool { return kindOf(path) == .file; } /// What `path` is on this host, null when it is not there; the turn out /// while it asks. pub fn kindOf(path: []const u8) ?std.Io.File.Kind { var buf: [4096]u8 = undefined; const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{path}, 0) catch return null; pardes.turn.yield(); defer pardes.turn.back(); const stat = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return null; return stat.kind; } /// With `BootShell replace`, a doc landing in `col` closes a lone pristine /// shell there, the boot's placeholder, and absorbVWeight hands its space to /// the doc. With `keep`, the default, the shell stays like any other pane. pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { if (p.settings.boot_shell == .keep) return; 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; } // ---- REPLs ---- // // A terminal is bound as a language's REPL by `Repl python` (builtins.zig). // An exec made by a gesture on the body of a file in that language -- or on // the REPL's own body -- is sent to it as input rather than run: all of it, // a word like `ls` too. The tag's words, `Exec` by name and a command word // `@`...`` still run as commands. /// A file pane's language, as grammar_manifest names it, by its index. pub fn langOf(pane: *const Pane) ?u8 { const f = pane.file orelse return null; return @intCast(@import("grammar_manifest.zig").byPath(f.path) orelse return null); } /// A bound REPL whose program is there to take input: a command pane's /// while its command runs; an interactive terminal's while a program, not /// the shell, has its tty (the host's `tty_taken`) -- a python3 left with /// Ctrl-D leaves the shell, which would run `import os` as ImageMagick's. pub fn replLive(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; if (pane.repl == null) return false; if (pane.command != null) return !pane.command_done; return p.hostTtyTaken(id); } /// `python-a`. pub fn replId(buf: []u8, pane: *const Pane) []const u8 { const r = pane.repl orelse return ""; return std.fmt.bufPrint(buf, "{s}-{c}", .{ grammars[r.lang].name, r.letter }) catch ""; } const Routed = union(enum) { none, sent: usize, asked }; /// The REPL bound here has no program running to take the text: said, /// and nothing sent or run. pub fn replGone(p: *Pardes, from: usize, repl: *const Pane) Routed { var idbuf: [48]u8 = undefined; var said: [128]u8 = undefined; p.setMessage(from, std.fmt.bufPrint(&said, "Repl: {s} has no program running; nothing sent", .{replId(&idbuf, repl)}) catch "Repl: no program running"); return .asked; } /// Where an exec from `from`'s body goes when a REPL is bound for it: the /// one there is, or a question when there are several. fn routeToRepl(p: *Pardes, from: usize, text: []const u8) Routed { const src = p.panes[from] orelse return .none; if (src.repl != null) { if (!replLive(p, from)) return replGone(p, from, src); sendToRepl(p, from, from, text); return .{ .sent = from }; } const lang = langOf(src) orelse return .none; // Every REPL bound for the language whose program still runs: one // whose program exited is skipped, in the question and in the choice. var bound: [26]usize = undefined; var n: usize = 0; var exited: ?usize = null; for (p.panes, 0..) |slot, i| if (slot) |pane| if (pane.repl) |r| if (r.lang == lang and n < bound.len) { if (!replLive(p, i)) { exited = i; continue; } bound[n] = i; n += 1; }; // Bound but none running: said, never run as a command instead. if (n == 0) return if (exited) |i| replGone(p, from, p.panes[i].?) else .none; if (n == 1) { if (!replLive(p, bound[0])) return replGone(p, from, p.panes[bound[0]].?); sendToRepl(p, from, bound[0], text); return .{ .sent = bound[0] }; } // Several: always asked on the pane's notice band, as Del asks, one key // answering; nothing is remembered of the answer. const owned = p.gpa.dupe(u8, text) catch return .none; if (p.repl_ask) |old| p.gpa.free(old.text); p.repl_ask = .{ .from = src.serial, .lang = lang, .text = owned }; for (bound[0..n]) |i| p.repl_ask.?.offered[p.panes[i].?.repl.?.letter - 'a'] = p.panes[i].?.serial; var w = std.Io.Writer.fixed(&src.question); w.print("send to {s}:", .{grammars[lang].name}) catch {}; for (bound[0..n]) |i| w.print(" {c}", .{p.panes[i].?.repl.?.letter}) catch {}; w.writeAll(" (Esc: none)") catch {}; src.question_len = @intCast(w.buffered().len); src.prompt = .repl_choice; p.active = from; var rec: [96]u8 = undefined; var rw = std.Io.Writer.fixed(&rec); rw.print("{d} repl", .{src.serial}) catch {}; for (bound[0..n]) |i| rw.print(" {c}", .{p.panes[i].?.repl.?.letter}) catch {}; pardes.ctlfs.events.notePath(p, "ask", rw.buffered()); return .asked; } const repl_enter_wait_ns: u64 = 250 * std.time.ns_per_ms; /// Python echoes a long paste over several reads: its Enter waits for the /// echo to go quiet this long, not for the first of it. const repl_quiet_ns: u64 = 100 * std.time.ns_per_ms; /// The host's clock now; core time (`now_ns`, `stepped_ns`) is as old as /// the last frame, seconds in an idle session. fn clockNow(p: *Pardes) u64 { return if (p.host.vtable.now) |clock| clock(p.host.ctx) else p.now_ns; } /// Sends the Enters a paste into pane `id`'s REPL is owed: when the REPL /// has answered it (`answered`), or once the wait is up. Python's answer /// only restarts a short wait, so the Enter goes once its echo of the /// whole paste is in (a multi-line paste read in part would leave it at /// `...`). pub fn flushReplEnter(p: *Pardes, id: usize, answered: bool) void { const pane = p.panes[id] orelse return; if (pane.repl_enter == 0) return; if (answered and (if (pane.repl) |r| r.lang == python_lang else false)) { pane.repl_enter_due_ns = clockNow(p) + repl_quiet_ns; return; } if (!answered and p.now_ns < pane.repl_enter_due_ns) return; for (0..pane.repl_enter) |_| p.emitWrite(id, "\r"); pane.repl_enter = 0; } fn pythonBlockOpen(text: []const u8) bool { const trimmed = std.mem.trimEnd(u8, text, " \t\n"); const start = if (std.mem.lastIndexOfScalar(u8, trimmed, '\n')) |nl| nl + 1 else 0; const last = trimmed[start..]; return last.len > 0 and (last[0] == ' ' or last[0] == '\t' or last[last.len - 1] == ':'); } /// Types `text` into the REPL in `to`, bracketed if its program asked for /// that, then Enter; says so on `from`'s message row and in the log. pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void { const dst = p.panes[to] orelse return; const src = p.panes[from] orelse return; var idbuf: [48]u8 = undefined; const id = replId(&idbuf, dst); // Lines end in \n alone (a \r\n would give Python a blank line that // ends its block), and no ESC or other control byte but a tab goes: an // ESC[201~ in the text would end a bracketed paste early. const clean = p.scratch.allocator().alloc(u8, text.len) catch return; var n: usize = 0; for (text, 0..) |c, i| { if (c == '\r') { if (i + 1 < text.len and text[i + 1] == '\n') continue; clean[n] = '\n'; } else if (c < ' ' and c != '\t' and c != '\n' or c == 0x7f) continue else clean[n] = c; n += 1; } // Sent whole or not at all: a send cut short would leave the REPL in a // paste, or with half a block. if (n + 16 > p.writeRoom(to)) { var said: [128]u8 = undefined; return p.setMessage(from, std.fmt.bufPrint(&said, "Repl: {d} bytes is more than can be sent at once; send less", .{n}) catch "Repl: too long to send"); } const pasted = panes.terminal.bracketedPaste(dst); edit.typeToTty(p, to, dst, clean[0..n]); // A Python block whose last line is indented, or a bare header, is not // over at one Enter, pasted into 3.13's REPL or typed into the old one: // the blank line a second Enter gives ends it. // So is a paste of more than one line: 3.13's REPL takes it as one // input, which one Enter leaves at `...`. const python = if (dst.repl) |r| r.lang == python_lang else false; const lines = std.mem.count(u8, std.mem.trim(u8, clean[0..n], " \t\n"), "\n") + 1; const enters: u8 = if (python and (pythonBlockOpen(clean[0..n]) or (pasted and lines > 1))) 2 else 1; if (pasted) { // An Enter read with the paste is part of it: 3.13's REPL takes it // as a newline in the pasted text, even a one-line one, and waits. // It goes once the REPL has answered the paste (its next output), // or after a moment if nothing comes. dst.repl_enter = enters; // From the clock now, not core time: idle (a detached session // nobody watches), `stepped_ns` and `now_ns` are as old as the last // frame, and a wait counted from there would be over already, the // Enter going out with the paste. dst.repl_enter_due_ns = clockNow(p) + repl_enter_wait_ns; } else for (0..enters) |_| p.emitWrite(to, "\r"); // Line by line, a blank line inside a Python block ends the block. if (!panes.terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and std.mem.indexOfScalar(u8, text, '\n') != null) { dst.repl_warned = true; var warn: [128]u8 = undefined; p.setMessage(from, std.fmt.bufPrint(&warn, "Repl: {s} takes no bracketed paste; a blank line in a block ends it", .{id}) catch "Repl: no bracketed paste"); } var said: [64]u8 = undefined; p.setMessage(from, std.fmt.bufPrint(&said, "\u{2192} {s}", .{id}) catch "sent"); src.msg_sent = true; var rec: [96]u8 = undefined; pardes.ctlfs.events.notePath(p, "send", std.fmt.bufPrint(&rec, "{d} {d} {s}", .{ src.serial, dst.serial, id }) catch return); } /// 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 { return executeFrom(p, id, txt, false); } /// `execute` for text a gesture took from `id`'s body when `from_body`: /// such text may be a bound REPL's. Run by name (`Exec ...`, whose line /// comes back through `execute`) or written as a command word, never. /// A line that starts with `#` is a comment, as in a shell: it runs as /// nothing, silently, wherever a line runs (exec, a ctl, the init file), /// so DumpConfig's report reads back as configuration whole. pub fn isComment(line: []const u8) bool { const t = std.mem.trimStart(u8, line, " \t"); return t.len > 0 and t[0] == '#'; } pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usize { if (isComment(commandText(txt))) return null; // An empty window runs its session words, and a command line in the // session's directory (execDir), as its workspace tag would. const pane = p.panes[id] orelse { const cmd = commandText(txt); if (cmd.len == 0 or executeBuiltinLine(p, id, cmd)) return null; return runCommand(p, id, cmd); }; const gesture = from_body and config.commandWord(std.mem.trim(u8, txt, " \t\r\n")) == 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 (gesture) switch (routeToRepl(p, id, cmd)) { .none => {}, .sent => |to| return to, .asked => return null, }; // A builtin or setting this build lacks (Fonts on a terminal) is said, // never run as a shell command. const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t") orelse cmd.len]; if (builtins.absentWhy(word) orelse if (config.Runtime.find(word) != null) @as([]const u8, "GUI-only") else null) |why| { var said: [96]u8 = undefined; p.reportFailure(id, std.fmt.bufPrint(&said, "{s} is {s}, invalid here", .{ word, why }) catch "GUI-only, invalid here"); return null; } // A command from a column's or the workspace's tag is that tag's: a // command pane in the session's directory, never typed into whichever // terminal has the keyboard. if (p.exec_header or !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, placed as placeNew places a command pane. /// 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 { // The board and the web page start no processes: a command pane there // would say running for ever, and fill the slots. if (comptime !hosted) { p.reportFailure(from, "exec: not a builtin, and this build runs no commands"); return null; } if (line.len > command_max) { p.reportFailure(from, "exec: a command line is at most 1024 bytes"); return null; } // None asking (an empty window): the session's directory, no pane of // its own to reuse. const src = p.panes[from]; const dir = p.execDir(from); // A directory that is not there runs nothing, as Tty makes nothing // there: no pane, and the write fails ENOENT. if (missingDir(p, dir)) { var said: [pardes.memory.limits.host_path_cap + 48]u8 = undefined; p.reportFailure(from, std.fmt.bufPrint(&said, "exec: {s}: no such directory", .{dir}) catch "exec: no such directory"); return null; } // Not one whose pty a job it left behind still prints to: reusing it // would hang that job up. // From a column's tag, only a finished pane in that column is reused, // as the new one goes in that column (placeNew). // Nor one leased: an open that was answered its serial and is still // open reads that pane's output, which another client's command must // not replace; a new pane runs it instead. const reuse: ?usize = if (!p.exec_header and p.exec_column == null and src != null and src.?.command != null and src.?.command_done and !src.?.command_pty and !leased(p, src.?.serial)) from else for (p.panes, 0..) |slot, i| { const other = slot orelse continue; if (p.exec_column) |c| if (if (layout.findPane(p, i)) |f| f.col != c else true) continue; if (other.command != null and other.command_done and !other.command_pty and std.mem.eql(u8, other.cwdSlice(), dir) and !leased(p, other.serial)) 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; p.diff_output.drop(p.gpa, pane.serial); pane.command_done = false; pane.command_status = null; pane.command_pty = true; 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, colours reset. (Not DECSTR: ghostty's stream does not // implement it.) 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[0m"); echoCommand(p, pane, line); pane.command_from = winid(p, src); p.emitSpawn(id, pane.serial, pane.cwdSlice()); noteRun(p, pane, "run", line); return id; } const free = p.freeSlot() orelse { p.reportError(from, "exec", error.NoPaneSlots); return null; }; const pane = p.newCommand(free, dir, line) catch |err| { p.reportError(from, "exec", err); return null; }; pane.command_from = winid(p, src); echoCommand(p, pane, line); placeNew(p, from, from, free, .command); if (refusedPlacement(p, free)) { p.reportError(from, "exec", error.NoPaneRoom); return null; } noteRun(p, pane, "run", line); return free; } /// Whether an open of a look or exec file holds `serial` from its own write /// (tree.Open.results): its client is reading that pane. fn leased(p: *const Pardes, serial: u32) bool { for (p.fs.opens) |o| if (o.node != 0 and o.results.wrote) { if (std.mem.indexOfScalar(u32, o.results.list[0..o.results.len], serial) != null) return true; }; return false; } /// The serial a command's `$winid` names: the pane it was run from, none /// (0) from a column's or the workspace's tag. fn winid(p: *const Pardes, src: ?*Pane) u32 { if (p.exec_header) return 0; return if (src) |s| s.serial else 0; } /// `% line` above a command's output, every command, as a shell echoes /// what it was given; its newlines the terminal's, so a multi-line command /// does not staircase. fn echoCommand(p: *Pardes, pane: *Pane, line: []const u8) void { var buf: [2 * command_max + 8]u8 = undefined; var w = std.Io.Writer.fixed(&buf); w.writeAll(if (panes.terminal.gridCursor(pane).x != 0) "\r\n% " else "% ") catch {}; for (line) |c| (if (c == '\n') w.writeAll("\r\n") else w.writeByte(c)) catch {}; w.writeAll("\r\n") catch {}; panes.terminal.feedOutput(p, pane, w.buffered()); } /// `run ` or `exit ` in the log. /// The whole line, one record as `msg` carries its text: its control /// characters, a newline too, become spaces (events.pushLog). A new pane is /// announced first, so `new` comes before the `run` in it. pub fn noteRun(p: *Pardes, pane: *Pane, what: []const u8, said: []const u8) void { pardes.ctlfs.events.announce(p); var buf: [command_max + 16]u8 = undefined; pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d} {s}", .{ pane.serial, std.mem.trim(u8, said, " \t\r\n") }) 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; // A REPL it was is gone with its command; the pane's next is not one. pane.repl = null; 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; } /// The builtin a command line's first word names, as acme's lookup reads /// only the first word (editors/acme/exec.c, lookup): what an event's /// flag 1 says. pub fn namesBuiltin(txt: []const u8) bool { const cmd = commandText(txt); const end = std.mem.indexOfAny(u8, cmd, " \t") orelse cmd.len; return std.meta.stringToEnum(Builtin, cmd[0..end]) != null; } /// acme's words, run by their pardes names (Put is Save; Delete is a Del /// that does not ask), or refused, never run as a shell command: a Put that /// exited 127 looked like a save that worked. pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint: []const u8 = "" }{ .{ .acme = "Put", .pardes = "Save" }, .{ .acme = "Delete", .pardes = "Del" }, .{ .acme = "Putall", .pardes = null, .hint = ": Save each pane" }, .{ .acme = "Snarf", .pardes = null, .hint = ": y yanks the selection" }, .{ .acme = "Cut", .pardes = null, .hint = ": d deletes the selection into a register" }, .{ .acme = "Paste", .pardes = null, .hint = ": p puts a register" }, .{ .acme = "Zerox", .pardes = null }, .{ .acme = "Sort", .pardes = null }, .{ .acme = "Load", .pardes = "Restore" }, .{ .acme = "ID", .pardes = null }, .{ .acme = "Send", .pardes = null }, .{ .acme = "Tab", .pardes = null }, .{ .acme = "Indent", .pardes = null }, .{ .acme = "Local", .pardes = null }, .{ .acme = "Incl", .pardes = null }, .{ .acme = "Abort", .pardes = null, .hint = ": a debugging word that crashes acme" }, }; pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { const raw = commandText(txt); if (raw.len == 0 or p.exec_depth >= max_exec_depth) return false; if (isComment(raw)) return true; const word_end = std.mem.indexOfAny(u8, raw, " \t") orelse raw.len; var renamed: [1024 + 16]u8 = undefined; const cmd = for (acme_words) |w| { if (!std.mem.eql(u8, raw[0..word_end], w.acme)) continue; const now = w.pardes orelse { var said: [128]u8 = undefined; p.reportFailure(id, std.fmt.bufPrint(&said, "invalid: acme's {s} is not a pardes builtin{s}", .{ w.acme, w.hint }) catch "invalid: an acme word pardes has not"); return true; }; // Delete: Del, the text thrown away unasked. if (std.mem.eql(u8, w.acme, "Delete")) if (p.panes[id]) |pane| if (pane.file) |f| { pane.discard_warned = .{ .revision = f.revision, .by = .Del }; }; break std.fmt.bufPrint(&renamed, "{s}{s}", .{ now, raw[word_end..] }) catch raw; } else raw; // `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 { applyInitText(p, p.opts.startup_config orelse return, false); } /// The init file saved in a pane: its settings applied again. Only its /// setting lines: a command in it (`Newcol`) is the session's start, and /// would not be right twice. pub fn reapplyInitFile(p: *Pardes, path: []const u8, text: []const u8) void { const init_path = p.opts.startup_config_path orelse return; if (!std.mem.eql(u8, init_path, path)) return; applyInitText(p, text, true); } fn applyInitText(p: *Pardes, text: []const u8, settings_only: bool) void { p.announce = false; defer p.announce = true; // A UTF-8 BOM an editor left at its start is no part of its first word. const body = if (std.mem.startsWith(u8, text, "\xEF\xBB\xBF")) text[3..] else text; var lines = std.mem.splitScalar(u8, body, '\n'); var number: usize = 0; while (lines.next()) |line| { number += 1; const word = std.mem.trim(u8, line, " \t\r"); // `#` starts a comment only at a line's start: a value may hold one // (`#ff0000`). if (word.len == 0 or isComment(word)) continue; const setting = config.Runtime.find(word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len]); if (settings_only and setting == null) continue; // One init file serves the window and the terminal: a setting only // the other has (Font, GripWidth in a terminal) is no failure here. if (setting) |s| if (!s.enabled(builtins.capabilities)) continue; const failures = p.fs.failures; const known = executeBuiltinLine(p, p.active, line); if (known and p.fs.failures == failures) continue; // Never silent: a word it has not, or one that failed, is a notice // on the message row and an err in the log, with its line. var why_buf: [320]u8 = undefined; const why: []const u8 = if (!known) std.fmt.bufPrint(&why_buf, "unknown word \"{s}\"", .{word[0..@min(word.len, 64)]}) catch "unknown word" else if (p.fs.said_len > 0) p.fs.said[0..p.fs.said_len] else "failed"; var said: [400]u8 = undefined; const line_said = std.fmt.bufPrint(&said, "init file line {d}: {s}", .{ number, why }) catch "init file: a line failed"; if (!known) p.reportFailure(p.active, line_said); pardes.ctlfs.events.notePath(p, "err -", line_said); } } pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { runBuiltinFrom(p, b, id, txt, arg, false); } /// `runBuiltin` saying whether a gesture on the pane's body made it. pub fn runBuiltinFrom(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8, from_body: bool) void { if (!normal.multiOnce(p)) return; // a builtin is per-keystroke, never per-cursor // No pane at all (the window Delcol emptied): a word runs all the same, // with a stand-in that is no pane (New, Newcol, Exit are what make sense). const pane = p.panes[id] orelse (if (builtins.registry.scope(b) == .session or p.anyPane() == null) p.standIn() orelse return else 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. It is the message // row's only, never /log's: a msg record is something said. if (p.announce and p.settings.verbose and builtins.registry.announces(b)) { p.fs.unlogged = true; p.setMessage(id, @tagName(b)); p.fs.unlogged = false; pane.msg_announcement = true; } // A word for one kind of pane, on another: refused with why, not a // silent nothing (Palette on a file, Filter on a PDF). if (builtins.registry.needs(b)) |kind| if (p.panes[id] == null or !kind.has(pane)) { var said: [96]u8 = undefined; return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: only {s} pane takes it", .{ @tagName(b), kind.noun() }) catch "only another kind of pane takes it"); }; const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg, .from_body = from_body }; 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; switch (setting.action) { .theme => { const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); if (colors.themeIndex(want)) |i| return colors.setThemeIndex(p, i); // A failure is said, the init file's too (applyStartupConfig // logs it with its line): never silent. // All of it within the 128 bytes a 9P error carries, so a write // failing with it is cut nowhere: the names get what is left. const shown = want[0..@min(want.len, 40)]; const room = 128 -| ("Theme: no theme \"\"; like it: ; Themes lists all".len + shown.len); var near: [128]u8 = undefined; var text: [256]u8 = undefined; return p.reportFailure(p.active, std.fmt.bufPrint(&text, "Theme: no theme \"{s}\"; like it: {s}; Themes lists all", .{ shown, colors.themesNear(near[0..@min(room, near.len)], 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; }, // A shell that is no executable is refused, not taken and quietly // replaced at the next spawn; bare, it goes back to the default. .shell => { const want = std.mem.trim(u8, arg orelse "", " \t\r\n"); if (want.len == 0) { p.settings.shell.requested.clear(); p.settings.shell.pending = true; if (!p.announce) return; var said: [320]u8 = undefined; return p.setMessage(p.active, std.fmt.bufPrint(&said, "Shell: the default again, {s} ($SHELL, else /bin/sh)", .{config.defaultShell()}) catch "Shell: the default again"); } if (comptime pardes.hosted) { var why: [320]u8 = undefined; if (@import("host_io.zig").Shell.refusal(want[0..@min(want.len, 200)], &why)) |refused| { var text: [340]u8 = undefined; return p.reportFailure(p.active, std.fmt.bufPrint(&text, "Shell: {s}", .{refused}) catch "Shell: no such shell"); } } if (!p.settings.apply(setting, want)) p.reportFailure(p.active, "Shell: takes " ++ comptime config.Runtime.takes(.shell).?); }, // Said with what it takes: its words, its range. else => if (!p.settings.apply(setting, arg)) { var text: [160]u8 = undefined; const why = if (config.Runtime.takes(setting.action)) |values| std.fmt.bufPrint(&text, "{s}: takes {s}", .{ setting.word, values }) else std.fmt.bufPrint(&text, "{s}: does not take that value", .{setting.word}); p.reportFailure(p.active, why catch "does not take that value"); }, } 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; // Idle frames keep no frozen grid while transitions are off; the one // just drawn is the baseline the first transition starts from. if (transition_changed and previous_transition == .off) p.presentation.capturePrevious(p.gpa, &p.surface); if (transition_changed or tagline_metrics_changed) { p.abandonPanelAnimations(); } } /// What a new pane is, and who asked: each rule places them differently. /// `scratch` is New's, `fs` 9P's pane/new, `errors` a +Errors pane. pub const NewPane = enum { scratch, fs, shell, doc, command, errors }; /// Put new pane `id` in the layout: every new pane comes through here, by /// the `Placement` setting, and every one but a command pane takes the /// keyboard. `from` is the pane that asked, `origin` the text it asked /// from: null when a column tag, the workspace tag or 9P asked (acme's /// t->w == nil). pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPane) void { // No column left (the last one Delcol'd): a pane made from the workspace // tag opens one to go in. _ = layout.firstColumn(p); const asked = if (p.exec_column != null) null else origin; layout.compute(p); const before = p.rects; switch (p.settings.placement) { .acme => switch (kind) { // New: the bottom half of the last pane in its own column // (look.c:921-923, et->col). .scratch => layout.acmePlace(p, p.exec_column orelse columnOf(p, from), null, id), // +Errors, a command's output: the last column (util.c:94-98); // a command run from a column's tag, that column. .command => layout.acmePlace(p, p.exec_column orelse p.ncol - 1, null, id), .errors => layout.acmePlace(p, p.ncol - 1, null, id), // makenewwindow(nil) for 9P's new (acme.c:877), else (t). .fs => layout.acmePlace(p, acmeColumn(p, from), null, id), .shell, .doc => layout.acmePlace(p, acmeColumn(p, from), asked, id), }, .pardes => pardesPlace(p, from, id, kind), } // No pane is made shorter than its tag and two rows: where the place // chosen has not the room, the column's tallest pane gives it, and // where none can, the pane is refused, and closed at the step's end // (Pardes.unplaced) once whatever made it is done with it. if (!layout.placedRoomy(p, id, &before)) if (layout.findPane(p, id)) |f| { layout.removePane(p, id); for (p.col_panes[f.col][0..p.col_n[f.col]]) |k| p.rects[k] = before[k]; layout.snapColWeights(p, f.col); if (!layout.splitTallest(p, f.col, id) or !layout.placedRoomy(p, id, &before)) { if (layout.findPane(p, id) == null) layout.insert(p, f.col, p.col_n[f.col], id); // No pane to halve, but the column holds every pane's minimum // with the new one's: the rows are shared out again, so what is // refused is only what the documented arithmetic cannot hold. // +Errors and 9P's pane/new go wherever there is room, last // column first, as acme takes rows from a window that has them // before saying there are none; with none, +Errors's maker says // the text some other way (appendErrors), and pane/new is // refused. if (layout.shareColumn(p, f.col)) {} else if (kind == .errors or kind == .fs) { layout.removePane(p, id); var c = p.ncol; while (c > 0) { c -= 1; if (c == f.col) continue; for (p.col_panes[c][0..p.col_n[c]]) |k| p.rects[k] = before[k]; layout.snapColWeights(p, c); if (p.col_n[c] == 0) { layout.insert(p, c, 0, id); break; } if (layout.splitTallest(p, c, id) and layout.placedRoomy(p, id, &before)) break; if (layout.findPane(p, id) != null) layout.removePane(p, id); } else { layout.insert(p, f.col, p.col_n[f.col], id); p.unplaced.set(id); // No pane anywhere can give it rows: pane/new is refused. if (kind == .fs) { p.panes[id].?.vweight = 0; p.reportError(from, "placement", error.NoPaneRoom); } return; } } else { // Refused, it closes at the step's end holding no weight, so // its column's rows come back as they were, none rounded off. p.panes[id].?.vweight = 0; p.unplaced.set(id); p.reportError(from, "placement", error.NoPaneRoom); return; } } }; // Placed: no pane of its column left under its minimum by the rows // rounding or an earlier placement gave it; shared out again if so. if (layout.findPane(p, id)) |f| if (!layout.columnAtMinimums(p, f.col)) { _ = layout.shareColumn(p, f.col); }; if (kind == .command) return; tagline.exitHeader(p); p.active = id; if (layout.findPane(p, id)) |f| p.setActiveColumn(f.col); } /// acme's makenewwindow column (util.c:456-467): the column a command came /// from (its tag's), else the active column, else the keyboard's pane's, /// else the asking pane's. fn acmeColumn(p: *Pardes, from: usize) usize { if (p.exec_column) |c| return c; if (p.activeColumn()) |c| return c; if (layout.findPane(p, p.active)) |f| return f.col; return columnOf(p, from); } fn columnOf(p: *Pardes, id: usize) usize { return if (layout.findPane(p, id)) |f| f.col else p.ncol - 1; } /// The placements pardes had before it took acme's: an empty column whose /// tag asked, or has the keyboard, is filled; a scratch goes under the /// asking pane, a shell under it or the nearest pane with room, a document /// beside the last one read or in a column of its own, and a command pane /// at the foot of the last column. fn pardesPlace(p: *Pardes, from: usize, id: usize, kind: NewPane) void { const empty = if (p.exec_column orelse if (p.header_focus) p.header_column else null) |c| (if (c < p.ncol and p.col_n[c] == 0) c else null) else null; if (empty) |c| { layout.insert(p, c, 0, id); p.panes[id].?.vweight = 1; return; } const nt = p.panes[id].?; switch (kind) { .scratch, .fs => if (layout.findPane(p, from)) |sf| { layout.insert(p, sf.col, sf.idx + 1, id); layout.splitBelow(p, from, nt); } else atFoot(p, id), .shell => belowParent(p, from, id), .doc, .errors => pardesDoc(p, from, id), .command => { const rc = p.exec_column orelse p.ncol - 1; if (p.col_n[rc] > 0) belowParent(p, p.col_panes[rc][p.col_n[rc] - 1], id) else layout.insert(p, rc, 0, id); }, } } /// Where a pane goes when the one it would go under is in no column (Delcol /// took them all, or it was never placed): the foot of the active column, /// else of the last. fn atFoot(p: *Pardes, id: usize) void { const c = p.activeColumn() orelse p.ncol - 1; layout.insert(p, c, p.col_n[c], id); if (p.col_n[c] == 1) p.panes[id].?.vweight = 1; } fn belowParent(p: *Pardes, from: usize, id: usize) void { const parent = layout.splitParent(p, from); const f = layout.findPane(p, parent) orelse return atFoot(p, id); layout.insert(p, f.col, f.idx + 1, id); layout.splitBelow(p, parent, p.panes[id].?); } fn pardesDoc(p: *Pardes, from_id: usize, free: usize) void { const nt = p.panes[free].?; 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 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)) return; // Without room for a first-document column, split the Look origin // just like Tty. Never displace (or evict) a top-left bystander. belowParent(p, src_id orelse from_id, 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(u16, 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(u16, 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")); }