From b8c5e8772e646fa21f6afdd2a875a1bd52848bd5 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 18:53:37 -0300 Subject: A column can be empty, as acme's can; new panes go where acme's makenewwindow puts them Newcol makes an empty column; closing a column's last pane leaves it empty with the keyboard on its tag; Delcol and Joincol alone take a column away; the session's last pane closing quits. The +New stand-in and replaceStillborn / stillborn_joiner are gone. Every new pane goes through exec.placeNew, which follows acme's makenewwindow (active column, empty column whole, blank space, halving the biggest) or, with Placement pardes, the old rules. ColumnTags is gone: column tags are always shown. A grip drag shows acme's box cursor. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/pardes-9p/SKILL.md | 9 +- docs/config.md | 8 +- docs/fs.md | 9 +- docs/helix-keys.md | 2 +- docs/tags.md | 68 +++++- docs/ui-review.md | 3 +- src/CHANGELOG.md | 16 ++ src/Mini.zig | 2 +- src/Output.zig | 10 +- src/builtins.zig | 25 +- src/config.zig | 24 +- src/draw.zig | 7 +- src/dump.zig | 17 +- src/exec.zig | 160 ++++++++----- src/fs-help.txt | 8 +- src/gui/gui.zig | 30 ++- src/layout.zig | 238 ++++++++++++++----- src/look.zig | 17 +- src/mouse.zig | 11 +- src/ninep/ctl.zig | 19 +- src/ninep/pane.zig | 4 +- src/ninep/tree.zig | 2 +- src/pardes.zig | 459 ++++++++++++++++++++++++------------- src/pdf_view.zig | 2 +- src/surface.zig | 4 +- src/tagline.zig | 1 + src/web/app.mjs | 3 +- test/appearance_gallery.py | 4 +- test/column_drag.zig | 5 - test/column_tags.py | 5 +- test/gui-goldens.txt | 13 +- test/gui_golden.py | 5 +- test/panes.zig | 1 - test/pdf.zig | 2 +- test/snapshots/argv.snap | 2 + test/snapshots/builtins.snap | 2 + test/snapshots/chordcut.snap | 2 + test/snapshots/cmdword.snap | 2 + test/snapshots/delask.snap | 2 + test/snapshots/doccol.snap | 2 + test/snapshots/emptycol.golden | 14 ++ test/snapshots/emptycol.snap | 22 ++ test/snapshots/filewatch.snap | 2 + test/snapshots/find.snap | 2 + test/snapshots/fsearch.snap | 2 + test/snapshots/grep.snap | 2 + test/snapshots/jumps.snap | 2 + test/snapshots/layout-close.golden | 5 +- test/snapshots/layout-close.snap | 8 +- test/snapshots/layout-open.snap | 2 + test/snapshots/leader.snap | 2 + test/snapshots/lookpanes.snap | 2 + test/snapshots/lookrange.snap | 2 + test/snapshots/lsp-client.snap | 2 + test/snapshots/lsp.snap | 2 + test/snapshots/lspcomplete.snap | 2 + test/snapshots/lsprelpath.snap | 2 + test/snapshots/mini.snap | 2 + test/snapshots/nested.snap | 2 + test/snapshots/psearch.snap | 2 + test/snapshots/reflow.snap | 2 + test/snapshots/rsearch.snap | 2 + test/snapshots/savepane.snap | 2 + test/snapshots/scrolloff.snap | 2 + test/snapshots/splitparent.snap | 2 + test/snapshots/stepgrain.snap | 2 + test/snapshots/tagalign.snap | 2 + test/snapshots/tagalign2col.snap | 2 + test/snapshots/tagbottom.snap | 2 + test/snapshots/tagnav.snap | 2 + test/snapshots/themesel.snap | 2 + test/snapshots/tinywin.snap | 2 + test/snapshots/topbarexec.snap | 2 + test/snapshots/ttyhelp.snap | 2 + test/snapshots/ttylook.snap | 2 + test/snapshots/ttyonly.snap | 2 + test/snapshots/ttytaken.snap | 2 + test/snapshots/windownav.snap | 2 + 78 files changed, 934 insertions(+), 388 deletions(-) create mode 100644 test/snapshots/emptycol.golden create mode 100644 test/snapshots/emptycol.snap diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 757ce64a..9473974c 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -68,7 +68,7 @@ $m/listeners this session's dial addresses $m/focus the serial of the pane with the keyboard (empty while a column/workspace tag has it); echo a serial into it to move the keyboard (a folded pane stays folded) $m/ctl the settings, one a line as a write takes them; write a setting or a session builtin - (Newcol, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the + (Newcol makes an empty column, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the commands pardes started (command panes, lines it typed into shells), a word matching a command line's first word; Exit and Restore refuse once, naming every unsaved pane, `, : Modified (Exit again to discard them all)`, and the same @@ -78,8 +78,11 @@ $m/ctl the settings, one a line as a write takes them; write a setting or a pane's builtins (Del, Save f, Collapse, which folds that pane, and the column word Delcol, which closes that pane's column) go to $m/pane//ctl $m/commands every builtin: `Word`, `Word arg`, then `root` or `pane` (which ctl takes it) -$m/pane/new open it to make a pane (a scratch named /+New), read names it; - rmdir $m/pane/ closes it; closing a column's last pane leaves a +New there +$m/pane/new open it to make a pane (a scratch named /+New), read names it; it goes in + the ACTIVE column (the one last typed or clicked in, or Newcol's), filling it if + empty, else taking the bottom half of its last pane (ctl `Placement pardes`: the old rules); + rmdir $m/pane/ closes it; a column's last pane leaves the column EMPTY + (focus reads empty, the log says only del), and the session's LAST pane QUITS it $m/pane//errors write-only: text appended to the +Errors pane of the pane's directory $m/os/ the host filesystem ``` diff --git a/docs/config.md b/docs/config.md index 16fd59e5..77ed68e7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -111,9 +111,11 @@ themes and all imported names remain available. `FocusTint` toggles the focused pane and column tag tints; it is on by default. Workspace, column and pane command text can be edited directly; see [editable tags](tags.md) for naming and saved-workspace behavior. -`ColumnTags` toggles the column command row in both GUI and TTY; it is on by -default. Add `ColumnTags` to startup configuration to reclaim that row on a -compact screen. Hiding it preserves your custom column commands. +The column command row is always shown; an old `ColumnTags` init line is +ignored with a message. +`Placement acme` (the default) puts new panes where acme would, in the active +column; `Placement pardes` brings back pardes's own rules, which open a first +document in a column of its own. See [where new panes go](tags.md#where-new-panes-go). `Verbose` toggles the message-row announcement every builtin makes of its own name before it runs; it is on by default, and the builtins that own the message row themselves (`Msg`) never announce. Turning it off leaves the row to the diff --git a/docs/fs.md b/docs/fs.md index 98a8638d..eaaedf02 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -69,8 +69,13 @@ for any process, and `$NINE_MOUNT/pardes/NAME/` a `--detach=NAME` session's. 9ns exports `$NINE_MOUNT` to everything it starts, so a script checks that variable to know the mount is there, and takes the name from `$PARDES_9P` (`pardes-9p-.sock`). A new pane made through `pane/new` is a scratch named -`/+New` until it is given a name, and closing a column's last pane -leaves such a `+New` in its place (`Delcol` closes the column). +`/+New` until it is given a name. A column may hold no pane, as in acme: +`Newcol` makes one empty, and closing a column's last pane leaves it empty +with its tag holding the keyboard (`focus` reads empty) and logs only the +`del`. `pane/new` places its pane as acme's makenewwindow(nil) does: in the +active column, filling it when it is empty, else taking the bottom half of its +last pane ([where new panes go](tags.md#where-new-panes-go)). `Delcol` closes the column. Closing the +session's last pane quits pardes; see [tags](tags.md#empty-columns). For [Linux v9fs](https://www.kernel.org/doc/html/latest/filesystems/9p.html), use `version=9p2000,cache=none,access=any` and `trans=unix`, or `trans=tcp` diff --git a/docs/helix-keys.md b/docs/helix-keys.md index c703a3da..8874bf08 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -127,7 +127,7 @@ language-backend queries, and the shell pipe. | `Enter` (normal) | acme **look** chord: EXPLICIT selection, else file-ish word under cursor | pardes-specific, keep (helix normal-mode Enter unbound). Covers helix `gf`. Implicit motion residue falls back to the cursor word | pardes-specific | | `Tab` (normal) | acme **execute** chord | pardes-specific, keep; explicit-selection rule as Enter | pardes-specific | | `:` (normal, body or tag) | in the body, focuses the pane's tag in normal mode at its remembered cursor (the first time, on `Save`); in the tag, goes back to the body; in a column or workspace tag, back to the active pane. The tag's normal and insert modes ARE the body's: every motion, selection, edit and undo works there, and `0` goes to the line's start, the path's. Tab runs the word under the cursor or the selection and Enter looks it up (in a column or workspace tag Enter runs it too), and either hands the keyboard back to the body first. Clicks choose a new cursor position and type into the tag. | Each tag keeps its own cursor during the session. The computed path/marker/page is reachable and yankable but read-only: an edit into it is refused, and typing into a file's path drafts a new name. File-name changes are staged as described in [editable tags](tags.md). | pardes-specific | -| `Ctrl-w k` / `SPC w k` (pane with nothing above) | focuses its column's tag, then the workspace tag; with `ColumnTags` disabled it goes directly to the workspace. `Ctrl-w j` walks back to the panes, `Ctrl-w h`/`l` walk the column tags. Headers edit exactly as a pane tag does. | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md). | pardes-specific | +| `Ctrl-w k` / `SPC w k` (pane with nothing above) | focuses its column's tag, then the workspace tag. `Ctrl-w j` walks back to the panes, `Ctrl-w h`/`l` walk the column tags. Headers edit exactly as a pane tag does. | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md). | pardes-specific | | `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — editor normal mode only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; Raw **tty** mode forwards Ctrl-w to the child. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode it moves focus to the neighbour's BODY, as from a body, and from the top pane `Up` reaches the column and workspace tags | pardes-specific | | `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` (select next sibling), which pardes spells `Alt-right` alone | pardes-specific | | `Alt-c` | move active terminal to a fresh column (outside raw tty) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) | diff --git a/docs/tags.md b/docs/tags.md index fdea1472..7787d7f1 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -1,8 +1,9 @@ # Editable tags Pardes has three levels of command text: the workspace tag, one tag per -column, and each pane's tag. Column commands run in that column's active -pane (or its first pane when focus comes from another column). This makes +column, and each pane's tag. Column commands act on that column and run in +its active pane (or its first pane when focus comes from another column); a +column with no pane is described under [empty columns](#empty-columns). This makes `New`, `Tty`, `Find`, and `Grep` available beside the work they act on. `New` appears only in the column tag by default; pane tags keep their own save, terminal, close, and collapse commands. `Tty` opens a new embedded terminal. @@ -152,7 +153,7 @@ upgrade to `Mode`; customized command text is preserved. ## Saved workspaces `Dump` and `Restore` preserve customized workspace and column tags, including -intentionally empty tags. New columns start with the standard column tag. +intentionally empty tags, and columns that hold no pane. New columns start with the standard column tag. Closing a column keeps surviving columns' tags; `Joincol` keeps the destination column's tag. Old dumps without these optional fields retain the defaults. The automatic `Restore` shortcut does not overwrite a customized workspace @@ -163,7 +164,66 @@ than three rows it is omitted so a pane still has room. SDL and TTY share the same tag text, editing and layout; SDL additionally uses compact font sizing and subtle pixel separators. -`ColumnTags` toggles the column row (on by default) without deleting its text. +The column row is always shown. An old `ColumnTags` line in an init file is +ignored, with a message saying so. `FocusTint` controls active-column and active-pane emphasis. SDL also honors the shared bold, underline, and strikethrough attributes, including diagnostic underlines and the optional `SyntaxBold` keyword weight. + +## Empty columns + +A column can hold no pane, as acme's can: its tag stands over blank space, the +frame's own fill, where acme paints white (cols.c:186-188). `Newcol` makes an +empty column right of the keyboard's and gives its tag the keyboard. Closing a +column's last pane (`Del`, `Del k`/`Del j`, a shell exiting, a drag to another +column) leaves the column empty where it was, and the keyboard goes to its tag +if it was on that pane. Only `Delcol` and `Joincol` take a column away. + +`Delcol` and `Joincol` from a column's tag act on that column; `Delcol` +written to a pane's ctl closes that pane's column. A pane dragged onto an +empty column fills it. + +## Where new panes go + +Every new pane goes through one placement, chosen by the `Placement` setting: +`acme` (the default) or `pardes`. `Placement pardes` or `Placement acme` sets +it, in an init file, a tag or the root ctl; bare `Placement` flips it; `SPC c +p` is its leader path, and `Config` reports it. + +`Placement acme` is acme's makenewwindow (util.c:449-495). The core keeps +acme's *active column* (activecol, dat.c:37): the column last typed in +(acme.c:487), clicked in with the select button (acme.c:659), dropped into by +a grip (acme.c:640), whose tag was given the keyboard (`Newcol`, an emptied +column, `Ctrl-w k`), or that was given the last new pane (util.c:467). A Look +click moves the keyboard but not the active column, as button 3 does not in +acme. A new pane goes into the column a command's tag belongs to when it came +from a column tag, else the active column, else the keyboard's pane's, and +never into a new column: + +- an empty column it takes whole (util.c:468-469); +- from a tag, or 9P's `pane/new` (acme's `t->w == nil`), it takes the bottom + half of the column's last pane (coladd, cols.c:62-65); +- from a pane's text (a Look, `Tty`, `Alt-n`, a Grep or Find listing), it goes + right under the text of the pane with the most blank rows when that is more + than 15 rows, or more than 3 and more than half the biggest pane + (util.c:482-486); otherwise it halves the biggest pane, or the asking pane + when that is in the column and not much smaller (util.c:487-491); +- `New` goes into its own column, the bottom half of its last pane + (look.c:921-923); +- a command pane or a `+Errors` pane goes to the last column, the bottom + half of its last pane (util.c:94-98). + +`Placement pardes` is what pardes did before: an empty column whose tag asked, +or has the keyboard, is filled; a scratch goes right under the pane that asked; +a shell under it or the nearest pane with room; a document beside the last one +read, or in a column of its own on the left when there is none and the column +is at least 200 cells wide; a command pane at the foot of the last column. + +Down from an empty column's tag stays there, and the pane-to-pane keys pass +over an empty column; Left and Right from a tag walk every column's tag. + +One divergence from acme: acme keeps running when its last window closes, +every column empty. Pardes quits when the session's last pane closes, as +`Delcol` of the last column always has. A key, a prompt or a command in +pardes runs in a pane, so a session without one would have nothing to run +them in. diff --git a/docs/ui-review.md b/docs/ui-review.md index 58a42e61..1180a20e 100644 --- a/docs/ui-review.md +++ b/docs/ui-review.md @@ -98,8 +98,7 @@ tests and 797 SDL tests; SDL image/PDF and Kitty PDF rendering harnesses pass. ## Column and editable-tag follow-up The follow-up adds editable workspace and column command rows, compact pane -tags, staged buffer-name edits, caret reveal for long tags, and `ColumnTags` -to reclaim the extra row when needed. See [editable tags](tags.md) for the +tags, staged buffer-name edits, and caret reveal for long tags. See [editable tags](tags.md) for the exact interaction and save-target rules. `test/column_tags.py` exercises isolated SDL and TTY sessions against the same diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index f8451baf..874082d8 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -2,6 +2,22 @@ ## 0.0.3 +- A column can be empty, as in acme. `Newcol` makes an empty column and + gives its tag the keyboard; closing a column's last pane, or dragging it + away, leaves the column where it was with nothing in it, drawn as its tag + over blank space. The `+New` placeholder that used to stand in an emptied + column, and the machinery that swapped it out when something else arrived, + are gone. Closing the session's last pane now quits. +- New panes go where acme's makenewwindow puts them: into the active column + (the one last typed or clicked in), filling it when it is empty, else under + the text of a pane with room to spare, else halving the biggest pane; never + into a new column. `Placement pardes` brings back the old rules, which open a + first document in a column of its own. +- Column tags are always shown: the `ColumnTags` setting is gone, and an + init file's `ColumnTags` line is ignored with a message. +- Carrying a pane or a column by its grip shows acme's box cursor in the SDL + shell and a move cursor on the web. + - `Del` takes a side. `Del k` and `DelAbove` give the closed pane's rows to the expanded pane above it, `Del j` and `DelBelow` to the one below. A bare `Del` from the keyboard on a pane with panes both above and below asks on diff --git a/src/Mini.zig b/src/Mini.zig index e118e738..ac9a8e85 100644 --- a/src/Mini.zig +++ b/src/Mini.zig @@ -188,7 +188,7 @@ pub fn open(p: *Pardes, id: usize, argument: []const u8) !void { .syntax_dirty = false, }; pane.body.cur_pinned = true; - exec.placeDoc(p, id, free, pane); + exec.placeNew(p, id, id, free, .doc); p.active = free; } diff --git a/src/Output.zig b/src/Output.zig index 201b5491..2f462fd1 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -479,7 +479,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: [] const free = p.freeSlot() orelse return error.NoPaneSlots; const np = try open(p, free, dir, from, arg, result_content); np.file.?.location_rows = result_rows; - exec.placeDoc(p, id, free, np); + exec.placeNew(p, id, id, free, .doc); p.active = id; if (traits(from).steps) { pane.search_pane = free; @@ -558,8 +558,8 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void { const pane = p.panes[id] orelse return error.MissingPane; const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, "", content); - exec.placeDoc(p, id, free, np); + _ = try open(p, free, dir, from, "", content); + exec.placeNew(p, id, id, free, .doc); p.active = id; pane.search_pane = free; pane.search_row = null; @@ -777,7 +777,7 @@ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, arg, content); - exec.placeDoc(p, id, free, np); + _ = try open(p, free, dir, from, arg, content); + exec.placeNew(p, id, id, free, if (from == .errors) .errors else .doc); p.active = free; } diff --git a/src/builtins.zig b/src/builtins.zig index 72df0271..c2b4d62f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -169,7 +169,7 @@ pub const registry = struct { inline for (comptime settingList(), manualBuiltinCount()..) |setting, i| if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, and DumpDir bare is the default - .toggle, .transition, .scene, .dump_dir => false, + .toggle, .transition, .scene, .dump_dir, .placement => false, .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true, }; unreachable; @@ -818,11 +818,11 @@ pub const New = struct { } }; -/// The same empty scratch, opened in a fresh column beside the calling pane. +/// An empty column right of the keyboard's, its tag taking the keyboard. pub const Newcol = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { - c.p.newScratchColumn(c.id); + c.p.newColumn(c.id); } }; @@ -937,7 +937,10 @@ pub const Collapse = struct { pub const Delcol = struct { pub fn run(c: Ctx) void { - c.p.removeColumn(c.id) catch |err| c.p.reportError(c.id, "close column", err); + // The column of the tag it came from, else the pane's: a pane ctl's + // Delcol closes that pane's column wherever the keyboard is. + const col = c.p.exec_column orelse (layout.findPane(c.p, c.id) orelse return).col; + c.p.removeColumn(col) catch |err| c.p.reportError(c.id, "close column", err); } }; @@ -1049,9 +1052,9 @@ pub const Joincol = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { // The keyboard's column joins the one to its right; the last has none. - const at = layout.findPane(c.p, c.p.active) orelse return; - if (at.col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right"); - layout.joinCol(c.p); + const col = c.p.keyboardColumn(c.p.active) orelse return; + if (col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right"); + layout.joinCol(c.p, col); } }; @@ -1059,8 +1062,8 @@ pub const Tutor = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { const free = c.p.freeSlot() orelse return; - const nt = c.p.openTutorView(free) catch return; - exec.placeDoc(c.p, c.id, free, nt); // a doc like any other + _ = c.p.openTutorView(free) catch return; + exec.placeNew(c.p, c.id, c.id, free, .doc); // a doc like any other } }; @@ -1221,8 +1224,10 @@ pub const Down = struct { return tagline.enterHeader(c.p, if (column) |at| at.col else 0); // Onto the top pane of the column the header stands over. const col = c.p.header_column orelse if (column) |at| at.col else return; + // An empty column's tag has nothing below it. + if (col < c.p.ncol and c.p.col_n[col] == 0) return; tagline.exitHeader(c.p); - if (col < c.p.ncol and c.p.col_n[col] > 0) c.p.active = c.p.col_panes[col][0]; + if (col < c.p.ncol) c.p.active = c.p.col_panes[col][0]; return; } _ = layout.focusDir(c.p, c.id, .down); diff --git a/src/config.zig b/src/config.zig index 2eaaa505..48f49d50 100644 --- a/src/config.zig +++ b/src/config.zig @@ -73,11 +73,11 @@ pub const leader_path = paths: { .Newcol = "cn", .Delcol = "cd", .Joincol = "cj", + .Placement = "cp", .Debug = "td", .Msg = null, .Colors = "tc", .FocusTint = null, - .ColumnTags = null, .SyntaxBold = null, .Verbose = null, .TreeContextTagStyle = null, @@ -616,7 +616,6 @@ pub const Runtime = struct { /// menu bar) turn it off and carry its commands there instead; the core /// keeps the row as the acme tradition everywhere else. workspace_tag: bool = true, - column_tags: bool = true, syntax_bold: bool = false, /// A font's programming ligatures (`->`, `!=`) draw across their cells. /// Only a shell that shapes text has the setting (`Capabilities.ligatures`). @@ -664,8 +663,12 @@ pub const Runtime = struct { /// never, while the window has the focus, or always (ghostty's /// custom-shader-animation). shader_animation: ShaderAnimation = .on, + /// Where a new pane goes (exec.placeNew): `acme`, acme's makenewwindow, + /// or `pardes`, the rules pardes had before it. + placement: Placement = .acme, pub const ShaderAnimation = enum { off, on, always }; + pub const Placement = enum { acme, pardes }; /// Each pass the bundled Crt or a Shadertoy file. pub const Post = struct { @@ -818,7 +821,7 @@ pub const Runtime = struct { /// The message timings a setting word sets, each in milliseconds. pub const MessageTime = enum { linger, fall, dissolve }; - pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures }; + pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures }; /// The bundled post pass, a Shadertoy file in shaders/post/. pub const Scene = enum { crt }; @@ -838,6 +841,7 @@ pub const Runtime = struct { /// it of files. shader, shader_animation, + placement, }; pub const Setting = struct { @@ -861,7 +865,6 @@ pub const Runtime = struct { .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } }, .{ .word = "Debug", .action = .{ .toggle = .debug } }, .{ .word = "FocusTint", .action = .{ .toggle = .focus_tint } }, - .{ .word = "ColumnTags", .action = .{ .toggle = .column_tags } }, .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } }, .{ .word = "Verbose", .action = .{ .toggle = .verbose } }, .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, @@ -890,6 +893,7 @@ pub const Runtime = struct { .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, .{ .word = "Shader", .action = .shader, .availability = .scene_shaders }, .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders }, + .{ .word = "Placement", .action = .placement }, }; pub fn find(name: []const u8) ?Setting { @@ -1048,6 +1052,14 @@ pub const Runtime = struct { const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); state.shader_animation = std.meta.stringToEnum(ShaderAnimation, text) orelse return false; }, + // Bare, it flips between the two, as a tag word should. + .placement => { + const text = std.mem.trim(u8, argument orelse "", " \t\r\n"); + state.placement = if (text.len == 0) + (if (state.placement == .acme) .pardes else .acme) + else + std.meta.stringToEnum(Placement, text) orelse return false; + }, .theme, .font => return false, } return true; @@ -1217,6 +1229,7 @@ pub const Runtime = struct { try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) }) else try out.print("{s} unsupported\n", .{setting.word}), + .placement => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.placement) }), }; if (context.startup_config_path) |path| @@ -1468,6 +1481,7 @@ pub const Runtime = struct { .dump_dir => "~/dumps", .shader => "~/crt.glsl", .shader_animation => "always", + .placement => "pardes", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1483,7 +1497,6 @@ pub const Runtime = struct { .tag_bottom => "Tagbottom on\n", .debug => "Debug on\n", .focus_tint => "FocusTint off\n", - .column_tags => "ColumnTags off\n", .syntax_bold => "SyntaxBold on\n", .verbose => "Verbose off\n", .message_animation => "MessageAnimation off\n", @@ -1518,6 +1531,7 @@ pub const Runtime = struct { }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", + .placement => "Placement pardes\n", .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); diff --git a/src/draw.zig b/src/draw.zig index 2d757635..b12ab52c 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -163,6 +163,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // the pointer per-text (the macOS I-beam) read this as "target" and // afford it the arrow instead. if (p.look_hover_preview != null) s.pointer_shape = .target; + if (p.drag == .move or p.drag == .column_move) s.pointer_shape = .box; for (&s.body_layers) |*layer| layer.rows = 0; for (&s.tag_layers) |*layer| layer.rows = 0; s.nimages = 0; @@ -974,14 +975,14 @@ test "place puts every part where the painters and layers put it, in every layou const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); _ = try p.setTestFile("placed\n" ** 80); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.newScratchBelow(0); p.sync(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); for (0..16) |step| { p.settings.tag_bottom = step & 1 != 0; - p.settings.column_tags = step & 2 != 0; if (step & 4 != 0) try std.testing.expect(p.executeBuiltinLine(p.active, "Msg a placed notice")); if (step == 8) try std.testing.expect(p.executeBuiltinLine(1, "Collapse")); if (step == 12) try std.testing.expect(p.executeBuiltinLine(1, "Collapse")); @@ -1121,7 +1122,7 @@ test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics nev var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const words = [_][]const u8{ "error:", "\u{754c}", "x", "Shader /tmp/a.glsl:", "e\u{301}", "\t", "long-" ** 20, " " }; - const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "ColumnTags", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" }; + const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" }; var text: std.ArrayListUnmanaged(u8) = .empty; defer text.deinit(std.testing.allocator); for (0..steps) |_| { diff --git a/src/dump.zig b/src/dump.zig index 954a3270..8e669b77 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -217,7 +217,8 @@ pub fn validate(state: State) !void { } for (state.columns) |col| { if (!std.math.isFinite(col.weight) or col.weight <= 0) return error.BadDumpColumns; - if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns; + // An empty column is a column, as in acme's dump (rows.c:639). + if (col.panes.len > max_panes) return error.BadDumpColumns; for (col.panes) |pane| { if (pane >= state.panes.len) return error.BadDumpPaneRef; } @@ -638,14 +639,12 @@ pub fn dumpState(p: *Pardes) !void { column_ids[c][ids_len] = compact; ids_len += 1; }; - if (ids_len > 0) { - columns[columns_len] = .{ - .tag = p.col_tags[c].own, - .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), - .panes = column_ids[c][0..ids_len], - }; - columns_len += 1; - } + columns[columns_len] = .{ + .tag = p.col_tags[c].own, + .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), + .panes = column_ids[c][0..ids_len], + }; + columns_len += 1; } var mounts: [filesystem.max_mounts]Mount = undefined; diff --git a/src/exec.zig b/src/exec.zig index ee7c31e9..117970d9 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -322,12 +322,7 @@ fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane { 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; + placeNew(p, from, from, free, .shell); return nt; } @@ -357,29 +352,6 @@ pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { 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; - 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); -} - // ---- REPLs ---- // // A terminal is bound as a language's REPL by `Repl python` (builtins.zig). @@ -552,7 +524,7 @@ pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usi /// 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. +/// 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 { @@ -607,13 +579,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { return null; }; echoCommand(p, pane, line); - 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); + placeNew(p, from, from, free, .command); noteRun(p, pane, "run", line); return free; } @@ -691,7 +657,24 @@ 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); + while (lines.next()) |line| { + if (executeBuiltinLine(p, p.active, line)) continue; + // A setting pardes no longer has is said, once, and changes nothing. + const word = commandText(line); + if (std.mem.eql(u8, word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len], "ColumnTags")) + p.setMessage(p.active, "ColumnTags: column tags are always shown now; the init line is ignored"); + } +} + +test "an init file's retired ColumnTags line is said and ignored" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.opts.startup_config = "ColumnTags\nWrap off\n"; + applyStartupConfig(p); + try std.testing.expect(!p.settings.wrap); + try std.testing.expect(p.columnBarHeight() > 0); + const pane = p.panes[p.active].?; + try std.testing.expectEqualStrings("ColumnTags: column tags are always shown now; the init line is ignored", pane.msg[0..pane.msg_len]); } pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { @@ -718,7 +701,6 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[] 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"); @@ -768,20 +750,98 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[] 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); - } // 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 or p.settings.column_tags != previous_column_tags) { + if (transition_changed or tagline_metrics_changed) { p.abandonPanelAnimations(); } } -pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { - p.stillborn_joiner = nt.serial; +/// 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 { + const asked = if (p.exec_column != null) null else origin; + 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). + .command, .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), + } + 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); + }, + .shell => belowParent(p, from, id), + .doc, .errors => pardesDoc(p, from, id), + .command => { + const rc = 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); + }, + } +} + +fn belowParent(p: *Pardes, from: usize, id: usize) void { + const parent = layout.splitParent(p, from); + const f = layout.findPane(p, parent).?; + 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()) { @@ -808,22 +868,14 @@ pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { 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; + belowParent(p, src_id orelse from_id, free); } test "the acme chords act once per selection, not once on the primary" { diff --git a/src/fs-help.txt b/src/fs-help.txt index 2b42be1f..0121a831 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -8,10 +8,10 @@ exec write a line: a middle click, an editor command word or a shell line log events: new/del/rename/save , run/exit/send ..., dump/restore , msg, err screen rendered screen as JSON, frozen from open to close listeners the session's dial addresses -focus the serial of the pane with the keyboard; write a serial to give it the keyboard -ctl settings, one a line; or Newcol, Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word) +focus the serial of the pane with the keyboard (empty on a column tag); write a serial to give it the keyboard +ctl settings, one a line; or Newcol (an empty column), Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word) commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it -pane/new open it to make a pane (named +New); the read answers that pane's serial +pane/new open it to make a pane (named +New) in the active column, filling it if empty; the read answers its serial pane// name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only: appended to its directory's +Errors pane) event look exec, pty/ on terminals; rmdir closes os/, src/ the host filesystem; the editor's own sources (-Dembed-sources=true builds only) @@ -41,5 +41,5 @@ Pitfalls, one each: A terminal's body is a history snapshot frozen per open; pty/data is the live stream. exec: a non-builtin line runs as a command pane (ctl's Shell -c; exit N shown, logged), a shell's typed in. Through a mount a malformed write is EINVAL, a well-formed one that fails EIO: log's err says why. - lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane -> +New. + lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane leaves it empty; the last pane of all quits. Repl python on a terminal's ctl: a .py body's clicks go to it (Repl - unbinds, Repl says it); tags stay commands. diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 84ec1c5c..26e9e24d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -73,6 +73,24 @@ const p9_arrow_set = [32]u8{ 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8, 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00, }; +/// acme's boxcursor (acme.c:956), while a grip carries a pane or column. +const p9_box_clr = [32]u8{ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, + 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; +const p9_box_set = [32]u8{ + 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, + 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, + 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, + 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, 0x00, 0x00, +}; +const p9_box_mask = blk: { + var m: [32]u8 = undefined; + for (&m, p9_box_set, p9_box_clr) |*b, s, cl| b.* = s | cl; + break :blk m; +}; const p9_arrow_mask = blk: { var m: [32]u8 = undefined; for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl; @@ -1847,6 +1865,7 @@ const Gui = struct { pointer_present: bool = false, arrow_cursor: ?*c.SDL_Cursor = null, link_cursor: ?*c.SDL_Cursor = null, + box_cursor: ?*c.SDL_Cursor = null, pointer_shape: pardes.Surface.PointerShape = .arrow, pointer_mapped: bool = false, pointer_cell: ?MouseCell = null, @@ -2292,6 +2311,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor); const link_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER); defer if (link_cursor) |cursor| c.SDL_DestroyCursor(cursor); + // Its hot spot the middle, as acme's {-7, -7} offset puts it. + const box_cursor = c.SDL_CreateCursor(&p9_box_set, &p9_box_mask, 16, 16, 7, 7); + defer if (box_cursor) |cursor| c.SDL_DestroyCursor(cursor); if (arrow_cursor) |cur| { _ = c.SDL_SetCursor(cur); } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); @@ -2427,6 +2449,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .window = window, .arrow_cursor = arrow_cursor, .link_cursor = link_cursor, + .box_cursor = box_cursor, .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, @@ -5270,7 +5293,11 @@ fn renderFrame( ) !bool { if (surface.pointer_shape != g.pointer_shape) { g.pointer_shape = surface.pointer_shape; - const cursor = if (g.pointer_shape == .link) g.link_cursor else g.arrow_cursor orelse c.SDL_GetDefaultCursor(); + const cursor = switch (g.pointer_shape) { + .link => g.link_cursor, + .box => g.box_cursor orelse g.arrow_cursor, + else => g.arrow_cursor orelse c.SDL_GetDefaultCursor(), + }; if (cursor) |value| _ = c.SDL_SetCursor(value); } g.scene_target_failed = false; @@ -5810,7 +5837,6 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand var notices = false; for (0..32) |step| { core.settings.tag_bottom = step & 1 != 0; - core.settings.column_tags = step & 2 != 0; if (step & 3 == 0) try std.testing.expect(core.executeBuiltinLine(2, "Collapse")); core.leader_on = step & 4 != 0; if (step & 8 != 0) { diff --git a/src/layout.zig b/src/layout.zig index f0e6fd16..c58f3bcf 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -19,7 +19,9 @@ pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS; pub const MovePlacement = struct { preview_col: usize, - above_id: usize, + /// The pane the moved one lands below; null when the column is empty + /// and the moved pane takes it whole. + above_id: ?usize, row: u16, above_y: u16, above_h: u16, @@ -60,6 +62,16 @@ pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) bo return true; } +/// Column `c`'s serial, handed out the first time it is asked for. +pub fn columnSerial(p: *Pardes, c: usize) u32 { + if (p.col_serial[c] == 0) { + p.col_serial[c] = p.next_column_serial; + p.next_column_serial +%= 1; + if (p.next_column_serial == 0) p.next_column_serial = 1; + } + return p.col_serial[c]; +} + pub fn columnBySerial(p: *const Pardes, serial: u32) ?usize { if (serial == 0) return null; for (0..p.ncol) |column| if (p.col_serial[column] == serial) return column; @@ -178,6 +190,7 @@ pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlaceme heights[sib] +|= p.rects[id].h; } var y: u16 = p.topBarHeight() + p.columnBarHeight(); + if (p.col_n[tc] == 0) return .{ .preview_col = tc, .above_id = null, .row = y, .above_y = y, .above_h = 0 }; var last: ?MovePlacement = null; for (0..p.col_n[tc]) |k| { const pid = p.col_panes[tc][k]; @@ -213,18 +226,23 @@ pub fn movePane(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void { } } removePane(p, id); - if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) { + if (source_multi) { const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx]; heights[sib] +|= p.rects[id].h; } - const af = findPane(p, placement.above_id) orelse return; + const above = placement.above_id orelse { + insert(p, placement.preview_col, 0, id); + if (source_multi) setColumnWeights(p, src.col, &heights); + return; + }; + const af = findPane(p, above) orelse return; const upper_h = @max(1, placement.row -| placement.above_y); const lower_h = @max(1, placement.above_h -| upper_h); - heights[placement.above_id] = upper_h; + heights[above] = upper_h; heights[id] = lower_h; insert(p, af.col, af.idx + 1, id); setColumnWeights(p, af.col, &heights); - if (source_multi and src.col < p.ncol and src.col != af.col) setColumnWeights(p, src.col, &heights); + if (source_multi and src.col != af.col) setColumnWeights(p, src.col, &heights); } pub fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void { @@ -278,39 +296,23 @@ pub fn insert(p: *Pardes, c: usize, idx: usize, id: usize) void { p.col_n[c] += 1; } +/// Take a pane out of its column. The column stays, empty if this was its +/// last pane, as acme's colclose leaves it (cols.c:186): only Delcol and +/// Joincol take a column away. pub fn removePane(p: *Pardes, id: usize) void { const f = findPane(p, id) orelse return; const c = f.col; var k = f.idx; while (k + 1 < p.col_n[c]) : (k += 1) p.col_panes[c][k] = p.col_panes[c][k + 1]; p.col_n[c] -= 1; - if (p.col_n[c] == 0) { - tagline.exitHeader(p); - if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; - p.col_tags[c].deinit(p.gpa); - var j = c; - while (j + 1 < p.ncol) : (j += 1) { - p.col_panes[j] = p.col_panes[j + 1]; - p.col_n[j] = p.col_n[j + 1]; - p.col_weight[j] = p.col_weight[j + 1]; - p.col_tags[j] = p.col_tags[j + 1]; - p.col_serial[j] = p.col_serial[j + 1]; - } - p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; - p.col_serial[p.ncol] = 0; - } } -pub fn joinCol(p: *Pardes) void { - const f = findPane(p, p.active) orelse return; - const c = f.col; - if (c + 1 >= p.ncol) return; +/// Close column `c` once its panes are gone, its width to the one left of +/// it (the first column's to its right), as acme's rowclose gives it. +pub fn dropColumn(p: *Pardes, c: usize) void { + std.debug.assert(p.col_n[c] == 0); tagline.exitHeader(p); - const dst = c + 1; - p.col_weight[dst] +|= p.col_weight[c]; - for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k]; - p.col_n[dst] += p.col_n[c]; + if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; p.col_tags[c].deinit(p.gpa); var j = c; while (j + 1 < p.ncol) : (j += 1) { @@ -325,42 +327,145 @@ pub fn joinCol(p: *Pardes) void { p.col_serial[p.ncol] = 0; } +/// Fold column `c` into the one on its right, its panes below theirs. +pub fn joinCol(p: *Pardes, c: usize) void { + if (c + 1 >= p.ncol) return; + const dst = c + 1; + for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k]; + p.col_n[dst] += p.col_n[c]; + p.col_n[c] = 0; + // The width goes right, where the panes went. + p.col_weight[dst] +|= p.col_weight[c]; + p.col_weight[c] = 0; + dropColumn(p, c); +} + +/// Put new pane `id` in column `c` as acme's makenewwindow does +/// (util.c:449-495): an empty column it takes whole; asked from a tag or +/// from 9P (`origin` null, acme's t->w == nil) it takes the bottom half of +/// the column's last pane (coladd with y < 0, cols.c:62-65). Asked from a +/// pane's text, it goes under the text of the pane with the most blank rows +/// when there are many, else it halves the biggest pane, or `origin` when +/// that is in the column and not much smaller. +pub fn acmePlace(p: *Pardes, c: usize, origin: ?usize, id: usize) void { + compute(p); + const n = p.col_n[c]; + if (n == 0) { + insert(p, c, 0, id); + p.panes[id].?.vweight = 1; + return; + } + var big: ?usize = null; + var empty: ?usize = null; + for (p.col_panes[c][0..n]) |k| { + const pane = p.panes[k] orelse continue; + if (pane.collapsed) continue; + // `>=`: at a tie the one nearer the bottom, as acme picks. + if (big == null or bodyRows(p, k) >= bodyRows(p, big.?)) big = k; + if (empty == null or blankRows(p, k) >= blankRows(p, empty.?)) empty = k; + } + const v0 = big orelse { + // Every pane folded: the new one goes last. + insert(p, c, n, id); + return; + }; + const y: u16 = y: { + const o = origin orelse { + var last = v0; + for (p.col_panes[c][0..n]) |k| if (!p.panes[k].?.collapsed) { + last = k; + }; + break :y bodyTop(p, last) + bodyRows(p, last) / 2; + }; + const e = empty.?; + const el = blankRows(p, e); + if (el > 15 or (el > 3 and el > (bodyRows(p, v0) -| 1) / 2)) + break :y bodyTop(p, e) + bodyRows(p, e) - el; + const mine = if (findPane(p, o)) |f| f.col == c and !p.panes[o].?.collapsed else false; + const v = if (mine and @as(u32, p.rects[o].h) * 3 > @as(u32, p.rects[v0].h) * 2) o else v0; + break :y p.rects[v].y + p.rects[v].h / 2; + }; + splitAt(p, c, id, y); +} + +/// acme's coladd at row `y` (cols.c:66-126): the new pane goes under the +/// pane that `y` falls in, which keeps its rows above `y`. +fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void { + const n = p.col_n[c]; + var k: usize = 0; + while (k + 1 < n and y >= p.rects[p.col_panes[c][k]].y + p.rects[p.col_panes[c][k]].h) k += 1; + const v = p.col_panes[c][k]; + const r = p.rects[v]; + snapColWeights(p, c); + insert(p, c, k + 1, id); + const pane = p.panes[id].?; + // Each keeps a tag and a body row, as acme's minht does. + const lo = r.y +| BOX_H + 1; + const hi = (r.y + r.h) -| (BOX_H + 1); + const row = splitRowForExtent(r.y, r.h, if (lo <= hi) std.math.clamp(y, lo, hi) else r.y + r.h / 2) orelse { + pane.vweight = BOX_H + 1; + return; + }; + // `y` fell in an unfolded pane: acmePlace picks from those. + p.panes[v].?.vweight = @floatFromInt(@max(1, row - r.y)); + pane.vweight = @floatFromInt(@max(1, (r.y + r.h) - row)); +} + +fn bodyTop(p: *const Pardes, id: usize) u16 { + return p.rects[id].y + p.panes[id].?.tag_rows; +} + +fn bodyRows(p: *const Pardes, id: usize) u16 { + return p.rects[id].h -| p.panes[id].?.tag_rows; +} + +/// The rows at the bottom of a pane's body that show nothing: acme's +/// maxlines - nlines. A picture or a PDF page fills its body. +fn blankRows(p: *const Pardes, id: usize) u16 { + const pane = p.panes[id].?; + const rows = bodyRows(p, id); + const used: usize = if (pane.file) |*f| + // The empty line after a final newline shows nothing. + (panes.File.nlines(p.gpa, f) -| @intFromBool(std.mem.endsWith(u8, f.content, "\n"))) -| f.scroll + else if (pane.isTerminal()) + @as(usize, panes.Terminal.gridCursor(pane).y) + 1 + else + rows; + return rows -| @as(u16, @intCast(@min(used, rows))); +} + pub fn canSplitColumn(p: *Pardes, source_id: usize) bool { - if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false; + if (source_id >= MAX_PANES or p.panes[source_id] == null) return false; const source = findPane(p, source_id) orelse return false; + return canSplitCol(p, source.col); +} + +fn canSplitCol(p: *Pardes, col: usize) bool { + if (p.ncol >= MAX_COLS or col >= p.ncol) return false; // Refresh derived widths: public layout surgery may be chained between // syncs, and a cached width must never admit a now-too-narrow split. compute(p); - if (p.col_w[source.col] < config.MINW * 2) return false; + if (p.col_w[col] < config.MINW * 2) return false; - const weight = p.col_weight[source.col]; + const weight = p.col_weight[col]; if (weight >= 2 and weight % 2 == 0) return true; for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2) return false; return weight > 0; } -pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { - if (id >= MAX_PANES or p.panes[id] == null) return false; - if (!canSplitColumn(p, source_id)) return false; - const source = findPane(p, source_id) orelse return false; - const source_col = source.col; - var old_weight = p.col_weight[source_col]; - const needs_rebase = old_weight < 2 or old_weight % 2 != 0; - if (needs_rebase) old_weight *= 2; - if (id == source_id) { - if (p.col_n[source_col] <= 1) return false; - _ = absorbVWeight(p, id, null); - removePane(p, id); - } else if (findPane(p, id) != null) return false; - - if (needs_rebase) { +/// An empty column beside column `col`, with half its width: acme's Newcol +/// (rowadd, rows.c:51). The new column's index, or null with no room. +pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize { + if (!canSplitCol(p, col)) return null; + var old_weight = p.col_weight[col]; + if (old_weight < 2 or old_weight % 2 != 0) { + old_weight *= 2; for (0..p.ncol) |column| p.col_weight[column] *= 2; } const source_weight = old_weight / 2; - const new_weight = old_weight - source_weight; - p.col_weight[source_col] = source_weight; - const c = source_col + @intFromBool(!before); + p.col_weight[col] = source_weight; + const c = col + @intFromBool(!before); tagline.exitHeader(p); var j = p.ncol; while (j > c) : (j -= 1) { @@ -370,12 +475,27 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { p.col_tags[j] = p.col_tags[j - 1]; p.col_serial[j] = p.col_serial[j - 1]; } - p.col_weight[c] = new_weight; + p.col_weight[c] = old_weight - source_weight; p.col_tags[c] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[c] = 0; - p.col_panes[c][0] = id; - p.col_n[c] = 1; + p.col_n[c] = 0; p.ncol += 1; + return c; +} + +/// Pane `id` alone in a new column beside `source_id`'s; `id` may be the +/// source itself when its column keeps another pane. +pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { + if (id >= MAX_PANES or p.panes[id] == null) return false; + if (!canSplitColumn(p, source_id)) return false; + const source = findPane(p, source_id) orelse return false; + if (id == source_id) { + if (p.col_n[source.col] <= 1) return false; + _ = absorbVWeight(p, id, null); + removePane(p, id); + } else if (findPane(p, id) != null) return false; + const c = insertColumn(p, source.col, before).?; + insert(p, c, 0, id); return true; } @@ -551,11 +671,7 @@ fn computeCollapsedColumn(p: *Pardes, c: usize, x: u16, width: u16, folded: usiz pub fn compute(p: *Pardes) void { p.rects = @splat(.{}); if (p.ncol == 0) return; - for (0..p.ncol) |column| if (p.col_serial[column] == 0) { - p.col_serial[column] = p.next_column_serial; - p.next_column_serial +%= 1; - if (p.next_column_serial == 0) p.next_column_serial = 1; - }; + for (0..p.ncol) |column| _ = columnSerial(p, column); var wsum: u128 = 0; for (0..p.ncol) |c| wsum += p.col_weight[c]; if (wsum == 0) wsum = 1; @@ -688,7 +804,6 @@ test "collapse local transfers remain bounded through repeated tiny window resiz } for (0..180) |step| { p.screen_h = @intCast((step * 17) % 81); - p.settings.column_tags = step % 2 == 0; p.settings.tag_bottom = step % 3 == 0; compute(p); toggleCollapse(p, step % 5); @@ -743,7 +858,7 @@ test "collapse single and all panes leave unused column space" { try std.testing.expectEqual(p.screen_h - 1, p.rects[1].y); } -test "collapse survives tiny resizes column tags and TagBottom" { +test "collapse survives tiny resizes and TagBottom" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); _ = try p.newShell(1, ""); @@ -753,8 +868,7 @@ test "collapse survives tiny resizes column tags and TagBottom" { p.panes[1].?.collapsed = true; for ([_]bool{ false, true }) |bottom| { p.settings.tag_bottom = bottom; - for ([_]bool{ false, true }) |column_tags| { - p.settings.column_tags = column_tags; + { for (0..50) |height| { p.screen_h = @intCast(height); compute(p); diff --git a/src/look.zig b/src/look.zig index 020aacb8..b6a89b64 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1251,20 +1251,15 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err); nt.greet = true; - const src = layout.splitParent(p, id); - const f = layout.findPane(p, src).?; - layout.insert(p, f.col, f.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; - p.stillborn_joiner = nt.serial; + exec.placeNew(p, id, id, free, .shell); }, .file => |target| { if (comptime pdf_enabled) if (target.kind == .pdf) { if (focusPaneByPath(p, target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| + _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); - exec.placeDoc(p, id, free, nt); + exec.placeNew(p, id, id, free, .doc); return; }; // focus an existing pane on this path (rescrolled), else open @@ -1273,7 +1268,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1); - exec.placeDoc(p, id, free, nt); + exec.placeNew(p, id, id, free, .doc); layout.compute(p); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; if (target.at.end_line != 0) { @@ -1285,9 +1280,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { .image => |target| { if (focusPaneByPath(p, target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = panes.Image.create(p, free, target.path, &.{}) catch |err| + _ = panes.Image.create(p, free, target.path, &.{}) catch |err| return p.reportError(id, "look", err); - exec.placeDoc(p, id, free, nt); + exec.placeNew(p, id, id, free, .doc); }, } } diff --git a/src/mouse.zig b/src/mouse.zig index 22eb3270..5f0a6c4b 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -337,6 +337,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { wordAtCol(bar, at); const word = p.scratch.allocator().dupe(u8, selected) catch return; tagline.exitHeader(p); + // A column tag's command acts on that column, empty or not. + p.exec_column = column; + defer p.exec_column = null; if (word.len > 0) { if (m.button == config.exec_button) { const named = std.meta.stringToEnum(Builtin, word); @@ -450,6 +453,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { const on_tag = mrow >= tag_y and mrow < tag_y + pane.tag_rows; if (on_tag and mcol < r.x + TAG_TEXT_INSET) return; p.active = id; + p.noteTyped(); // acme's button 1 sets activecol (acme.c:659) pane.closePrompt(); const pointed = edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return; pane.clearPointerSelection(sel_slot); @@ -775,12 +779,15 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y), .column_move => |d| if (layout.columnBySerial(p, d.serial)) |column| switch (layout.columnDrop(p, column, d.cur_x, d.grab)) { .stay => {}, - .reorder => |target| layout.reorderColumn(p, column, target), + .reorder => |target| { + layout.reorderColumn(p, column, target); + p.setActiveColumn(target); // a grip drag sets activecol (acme.c:640) + }, .edge => |x| layout.setColumnPairWidth(p, column - 1, x - p.col_x[column - 1]), }, .move => |d| { layout.movePane(p, d.id, d.cur_x, d.cur_y); - if (p.panes[d.id]) |mp| p.stillborn_joiner = mp.serial; + if (layout.findPane(p, d.id)) |f| p.setActiveColumn(f.col); // a file moved into the left column evicts a lone unused shell if (p.panes[d.id]) |mt| if (mt.file != null) { if (layout.findPane(p, d.id)) |f| if (f.col == 0) diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 18068ab1..b4ae7231 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -308,6 +308,7 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { .tagline_size => try w.print("{s} {d}\n", .{ word, s.font.tagline_percent }), .window_opacity => try w.print("{s} {d}\n", .{ word, s.window_opacity }), .window_blur => try w.print("{s} {d}\n", .{ word, s.window_blur }), + .placement => try w.print("{s} {s}\n", .{ word, @tagName(s.placement) }), .message_ms => |which| try w.print("{s} {d}\n", .{ word, @field(s, "message_" ++ @tagName(which) ++ "_ms") }), // Bare, it names the default directory. .dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}), @@ -1120,7 +1121,7 @@ test "Joincol with no column to the right and Theme with no such theme say so" { try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null); } -test "a look that fills a column's stillborn scratch is answered with the scratch already gone" { +test "a look after Newcol fills the empty column the keyboard is on" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); @@ -1130,14 +1131,12 @@ test "a look that fills a column's stillborn scratch is answered with the scratc var dir: [4096]u8 = undefined; const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; var line: [4200]u8 = undefined; - // Newcol makes a column of one empty +New; a file looked from it - // joins that column and takes the scratch's place. + // Newcol makes an empty column and gives its tag the keyboard; no pane + // has it, so focus reads empty. try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status); - const scratch = p.panes[p.active].?.serial; - try testing.expectEqual(tree.Status.ok, wr(p, Node.of(scratch, .look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status); - try testing.expect(p.paneBySerial(scratch) == null); - var want: [32]u8 = undefined; - const gone = try std.fmt.bufPrint(&want, "{d} ", .{scratch}); - var rows = std.mem.splitScalar(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, '\n'); - while (rows.next()) |row| try testing.expect(!std.mem.startsWith(u8, row, gone)); + try testing.expectEqual(@as(usize, 0), p.col_n[p.ncol - 1]); + try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.focus), 0, 64).bytes); + try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status); + try testing.expectEqual(@as(usize, 1), p.col_n[p.ncol - 1]); + try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]); } diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 714a2566..99acf24b 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -593,11 +593,11 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { } const free = p.freeSlot() orelse return null; const content = p.gpa.dupe(u8, text) catch return null; - const np = panes.Output.open(p, free, dir, .errors, "", content) catch { + _ = panes.Output.open(p, free, dir, .errors, "", content) catch { p.gpa.free(content); return null; }; - exec.placeDoc(p, id, free, np); + exec.placeNew(p, id, id, free, .errors); return text.len; } diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index b3f3dde6..5ef69bb4 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -577,7 +577,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { // closing it leaves the pane alone. .new => { const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE); - p.newScratchBelow(p.active); + p.newScratchFor(p.active, .fs); const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE); return .{ .tag = req.tag, .handle = made.serial }; }, diff --git a/src/pardes.zig b/src/pardes.zig index 00843b78..bad512a9 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1092,13 +1092,13 @@ test "Joincol folds the active column into its right neighbor, keeping its panes try std.testing.expectEqual(@as(usize, 2), p.ncol); p.active = 0; // the left column is current - layout.joinCol(p); + layout.joinCol(p, 0); try std.testing.expectEqual(@as(usize, 1), p.ncol); const lf = layout.findPane(p, 0) orelse return error.LostLeftPane; const rf = layout.findPane(p, right) orelse return error.LostRightPane; try std.testing.expectEqual(lf.col, rf.col); - layout.joinCol(p); // no right neighbor left: inert + layout.joinCol(p, 0); // no right neighbor left: inert try std.testing.expectEqual(@as(usize, 1), p.ncol); } @@ -1179,6 +1179,8 @@ test "first document fallback splits like Tty from the originating pane" { defer p.deinit(); const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); defer tty.deinit(); + p.settings.placement = .pardes; + tty.settings.placement = .pardes; if (collapsed) { layout.toggleCollapse(p, from); layout.toggleCollapse(tty, from); @@ -1191,8 +1193,8 @@ test "first document fallback splits like Tty from the originating pane" { // The Look origin need not be the currently active pane. p.active = (from + 1) % 3; const free = p.freeSlot().?; - const doc = try p.openTutorView(free); - exec.placeDoc(p, from, free, doc); + _ = try p.openTutorView(free); + exec.placeNew(p, from, from, free, .doc); _ = exec.spawnTty(tty, from); p.sync(); tty.sync(); @@ -1215,9 +1217,10 @@ test "first document keeps its originating shell and only opens a column when it for ([_]u16{ 199, 200 }) |width| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 }); defer p.deinit(); + p.settings.placement = .pardes; const shell = p.panes[0].?; - const doc = try p.openTutorView(1); - exec.placeDoc(p, 0, 1, doc); + _ = try p.openTutorView(1); + exec.placeNew(p, 0, 0, 1, .doc); p.sync(); try std.testing.expectEqual(shell, p.panes[0].?); try std.testing.expectEqual(@as(usize, 1), p.active); @@ -1233,6 +1236,112 @@ test "first document keeps its originating shell and only opens a column when it } } +test "acme placement: an empty active column takes the new pane whole" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 }); + defer p.deinit(); + try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement); + p.newColumn(0); // its tag has the keyboard: it is the active column + tagline.exitHeader(p); + p.sync(); + // A Look from the shell's text still lands in the active column, as + // acme's makenewwindow puts it in activecol first (util.c:456). + _ = try p.openTutorView(1); + exec.placeNew(p, 0, 0, 1, .doc); + try std.testing.expectEqualSlices(usize, &.{1}, p.col_panes[1][0..p.col_n[1]]); + try std.testing.expectEqual(@as(usize, 1), p.active); + try std.testing.expectEqual(@as(?usize, 1), p.activeColumn()); + // Never a new column. + try std.testing.expectEqual(@as(usize, 2), p.ncol); +} + +test "acme placement: under a pane's text when it has many blank rows, else halving the biggest" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 60 }); + defer p.deinit(); + const top = p.active; + _ = try p.setTestFile("one\ntwo\nthree\n"); + p.sync(); + p.setActiveColumn(0); + // Most of the pane is blank: a document looked from it starts under its + // three lines (makenewwindow, util.c:482-486). + const second = p.freeSlot().?; + _ = try p.openTutorView(second); + exec.placeNew(p, top, top, second, .doc); + p.sync(); + const text_end = p.bodyTop(p.panes[top].?, p.rects[top]) + 3; + try std.testing.expectEqual(text_end, p.rects[second].y); + // Fill both with text: no blank space is big, so the biggest pane is + // halved. + const full = "x\n" ** 80; + for ([_]usize{ top, second }) |id| panes.File.setContent(p, &p.panes[id].?.file.?, try p.gpa.dupe(u8, full)); + p.sync(); + const big = if (p.rects[second].h >= p.rects[top].h) second else top; + const was = p.rects[big]; + const third = p.freeSlot().?; + _ = try p.openTutorView(third); + exec.placeNew(p, second, second, third, .doc); + p.sync(); + try std.testing.expectEqual(big, p.col_panes[0][layout.findPane(p, third).?.idx - 1]); + try std.testing.expectEqual(was.y + was.h / 2, p.rects[third].y); + try std.testing.expectEqual(was.y + was.h, p.rects[third].y + p.rects[third].h); +} + +test "acme placement: New and 9P's new take the bottom half of the last pane" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); + defer p.deinit(); + p.newScratchBelow(p.active); + const last = p.active; + p.sync(); + const was = p.rects[last]; + p.newScratchFor(last, .fs); + const made = p.active; + p.sync(); + try std.testing.expectEqual(layout.findPane(p, last).?.idx + 1, layout.findPane(p, made).?.idx); + try std.testing.expectEqual(was.y + was.h, p.rects[made].y + p.rects[made].h); + try std.testing.expect(p.rects[made].y > was.y + BOX_H); +} + +test "Placement pardes brings back the old rules, and says so in Config" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); + defer p.deinit(); + try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes")); + try std.testing.expectEqual(config.Runtime.Placement.pardes, p.settings.placement); + const top = p.active; + p.sync(); + const was = p.rects[top]; + p.newScratchBelow(top); + p.sync(); + // Directly under the asking pane, as New always put it. + try std.testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.idx); + try std.testing.expectEqual(was.y + was.h, p.rects[p.active].y + p.rects[p.active].h); + try std.testing.expect(p.executeBuiltinLine(p.active, "Placement")); + try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement); +} + +test "dragging a column's only pane to another leaves that column in place, empty" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 }); + defer p.deinit(); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); + const moved = p.active; + p.sync(); + const weights = p.col_weight; + const tag_serial = p.col_serial[1]; + layout.movePane(p, moved, p.col_x[0] + 4, p.rects[0].y + p.rects[0].h / 2); + p.sync(); + // acme's coldragwin: colclose then coladd (cols.c:516-517); the column + // left behind keeps its place, width and tag. + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); + try std.testing.expectEqual(tag_serial, p.col_serial[1]); + try std.testing.expectEqualDeep(weights, p.col_weight); + try std.testing.expectEqual(@as(usize, 2), p.col_n[0]); + // ...and a pane dragged into the empty column takes it whole. + layout.movePane(p, moved, p.col_x[1] + 4, p.screen_h / 2); + p.sync(); + try std.testing.expectEqualSlices(usize, &.{moved}, p.col_panes[1][0..p.col_n[1]]); + try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); +} + test "pane defaults keep creation of scratch panes in column tags" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); @@ -3047,7 +3156,7 @@ test "column tags follow their column through split and close" { try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0)); p.col_tags[1].own = try p.gpa.dupe(u8, "Right Grep"); p.active = p.col_panes[0][0]; - layout.joinCol(p); + layout.joinCol(p, 0); try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0)); } @@ -3056,7 +3165,8 @@ test "column tags render edit hover and scroll from the column edge" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); p.acknowledgePanelPresentation(&.{}); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -3112,7 +3222,8 @@ test "column tag first-cell execution keeps New local to the clicked column" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); for (0..2) |column| { p.active = p.col_panes[1 - column][0]; @@ -3189,7 +3300,7 @@ test "tiny screens keep pane space before adding a column tag" { _ = try p.render(p.scratch.allocator()); } -test "ColumnTags reclaims the row and releases hidden header focus without losing commands" { +test "a screen too short for the column row releases a column tag's focus without losing its commands" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("unchanged body\n"); @@ -3201,24 +3312,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); try std.testing.expect(p.header_focus and p.header_column != null); - p.update(.{ .command = "ColumnTags" }); - try std.testing.expect(!p.settings.column_tags); - try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); - try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); - try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); - try std.testing.expect(!p.header_focus and p.header_column == null); - try std.testing.expect(!p.header_drag); - try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0)); - - p.update(.{ .command = "ColumnTags" }); - try std.testing.expect(p.settings.column_tags); - try std.testing.expectEqual(shown, p.rects[0]); - p.acknowledgePanelPresentation(&.{}); - p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); - p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); - try std.testing.expect(p.header_column != null); p.update(.{ .resize = .{ .cols = 60, .rows = 2 } }); - try std.testing.expect(p.settings.column_tags); try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); try std.testing.expect(!p.header_focus and p.header_column == null); @@ -3268,15 +3362,17 @@ test "Enter in a header runs the word under its cursor, as the execute key does" p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Newcol").?); p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expectEqual(@as(usize, 2), p.ncol); - // and the header lets go of the keyboard, as a pane tag does - try std.testing.expect(!p.header_focus); + // and the workspace tag lets go of the keyboard: the new, empty + // column's tag has it + try std.testing.expect(p.header_focus and p.header_column.? == 1); } test "window keys walk from a pane up through the column and workspace tags and back" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); p.active = p.col_panes[0][0]; const window = struct { @@ -3563,6 +3659,7 @@ test "Newcol refuses an unsplittable restored weight before spawning" { const pane = p.panes[id].?; try p.deinitPane(pane); layout.removePane(p, id); + layout.dropColumn(p, id); p.panes[id] = null; } while (p.nextEffect()) |_| {} @@ -4099,9 +4196,15 @@ pub const Pardes = struct { look_walk_owner: ?u32 = null, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, - /// The pane (by serial) that joined a column this step; `replaceStillborn` - /// checks it once the step is over and nothing holds the column's panes. - stillborn_joiner: ?u32 = null, + /// The column whose tag the running command came from, while it runs: + /// the column acme's commands take from their Text's col. + exec_column: ?usize = null, + /// acme's activecol (dat.c:37), by column serial, 0 for none: the + /// column last typed in (acme.c:487), clicked in with the select button + /// (acme.c:659), dropped into by a grip (acme.c:640), or given a new + /// pane (util.c:467). Where the acme placement puts new panes + /// (exec.placeNew). A Look click moves the keyboard but not this. + active_column: u32 = 0, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, locations_config: locations_config.Config = .{}, locations_cache: @import("locations_cache.zig").Cache = .{}, @@ -4347,10 +4450,12 @@ pub const Pardes = struct { p.panes[0].?.body.mode = .tty; p.oneColumn(); // A bare tty is still an editor: it opens with somewhere to put - // text under the shell. The shell keeps the keyboard. + // text under the shell. The shell keeps the keyboard. A boot + // layout, like .classic's, not a placement. if (p.boot == .tty) { - p.newScratchBelow(0); - p.active = 0; + const np = try p.newScratch(0, 1); + layout.insert(p, 0, 1, 1); + layout.splitBelow(p, 0, np); } }, .classic => { @@ -4469,7 +4574,7 @@ pub const Pardes = struct { /// The column tags' height: a row per line of the tallest one. pub fn columnBarHeight(p: *const Pardes) u16 { - if (!p.settings.column_tags or p.screen_h < 3) return 0; + if (p.screen_h < 3) return 0; var lines: usize = COLBAR_H; for (p.col_tags[0..p.ncol]) |t| if (t.own) |own| { lines = @max(lines, std.mem.count(u8, own, "\n") + 1); @@ -4609,34 +4714,56 @@ pub const Pardes = struct { return np; } - /// The empty pane that stands in for one that closed, keeping its column - /// alive and its directory. Unlike `newScratch` it inherits nothing: the - /// pane it replaces is on its way out. - fn newScratchIn(p: *Pardes, free: usize, dir: []const u8) !*Pane { - const content = try p.gpa.dupe(u8, ""); - errdefer p.gpa.free(content); - const np = try panes.Output.open(p, free, dir, .{ .cmd = .New }, "", content); - np.body.cur_pinned = true; - return np; - } - - /// New: a scratch below the calling pane, in its column. + /// New: an empty scratch in `from_id`'s directory, placed as exec.placeNew + /// places it. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { - const free = p.freeSlot() orelse return; - const sf = layout.findPane(p, from_id) orelse return; - const np = p.newScratch(from_id, free) catch return; - layout.insert(p, sf.col, sf.idx + 1, free); - layout.splitBelow(p, from_id, np); - p.active = free; + p.newScratchFor(from_id, .scratch); } - /// Newcol: a scratch in a fresh column beside the calling pane. - pub fn newScratchColumn(p: *Pardes, from_id: usize) void { + /// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`). + pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void { const free = p.freeSlot() orelse return; - if (!layout.canSplitColumn(p, from_id)) return; _ = p.newScratch(from_id, free) catch return; - std.debug.assert(layout.splitColumn(p, from_id, free, false)); - p.active = free; + exec.placeNew(p, from_id, from_id, free, kind); + } + + /// Newcol: an empty column right of the calling pane's, the keyboard + /// on its tag, for the next pane made to fill. + pub fn newColumn(p: *Pardes, from_id: usize) void { + const col = p.keyboardColumn(from_id) orelse return; + const c = layout.insertColumn(p, col, false) orelse return; + tagline.enterHeader(p, c); + } + + pub fn setActiveColumn(p: *Pardes, c: usize) void { + if (c < p.ncol) p.active_column = layout.columnSerial(p, c); + } + + /// The active column, while it is still there. + pub fn activeColumn(p: *const Pardes) ?usize { + return layout.columnBySerial(p, p.active_column); + } + + /// The column the keyboard is in: its tag's, or its pane's. + pub fn noteTyped(p: *Pardes) void { + if (p.header_focus) { + if (p.header_column) |c| p.setActiveColumn(c); + } else if (layout.findPane(p, p.active)) |f| p.setActiveColumn(f.col); + } + + /// New, run from column `c`'s tag. + pub fn newScratchIn(p: *Pardes, from_id: usize, c: usize) void { + p.exec_column = c; + defer p.exec_column = null; + p.newScratchBelow(from_id); + } + + /// The column a command acts on: the one whose tag it came from, else + /// the one whose tag has the keyboard, else `id`'s. + pub fn keyboardColumn(p: *Pardes, id: usize) ?usize { + if (p.exec_column) |c| return c; + if (p.header_focus) if (p.header_column) |c| return c; + return if (layout.findPane(p, id)) |f| f.col else null; } pub fn freeSlot(p: *Pardes) ?usize { @@ -5088,10 +5215,6 @@ pub const Pardes = struct { look.cancelLookHover(p); const reply = ctlfs.handle(p, req); if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply); - // A pane that joined a column holding only a stillborn scratch takes - // it now, before the answer: one read of index after the look shows - // the replacement done, and the log has its del with the new. - exec.replaceStillborn(p); // The request was a whole step of its own, so it settles the way a // step does: the cursor and scroll reconciled, the scripted panes // told, and the panes it made announced to /log now rather than at @@ -5103,7 +5226,6 @@ pub const Pardes = struct { } pub fn update(p: *Pardes, ev: Event) void { - defer exec.replaceStillborn(p); // A tick with nothing animating is the one event that cannot change // the screen. p.needs_frame = p.needs_frame or ev != .tick; @@ -5347,6 +5469,7 @@ pub const Pardes = struct { return; } const pane = p.panes[p.active] orelse return; + p.noteTyped(); // An open prompt takes the keys even when it answers for a header. const header = if (pane.prompt != .none) null else tagline.focusedHeader(p); if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) { @@ -5395,16 +5518,11 @@ pub const Pardes = struct { // global window ops (any mode): Alt-n new terminal below, Alt-c move // the active terminal into a fresh column if (hit(key, config.new_shell_below)) { - tagline.exitHeader(p); const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; - const src = layout.splitParent(p, p.active); - const f = layout.findPane(p, src).?; - layout.insert(p, f.col, f.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; - p.stillborn_joiner = nt.serial; + // From a tag, as a 9P new is: acme's t->w == nil. + exec.placeNew(p, p.active, if (p.header_focus) null else p.active, free, .shell); return; } for (config.jump_keys) |jk| { @@ -5483,6 +5601,9 @@ pub const Pardes = struct { const b = config.wordBounds(shown, modal.selectionCursor(shown, range)); break :word shown[b.lo..b.hi]; }; + // A column tag's command acts on that column, empty or not. + p.exec_column = if (header != null) p.header_column else null; + defer p.exec_column = null; // Out of the tag or header FIRST: Del frees the pane, Kill // and Restore the whole session. if (header != null) tagline.exitHeader(p) else pane.focus = .body; @@ -5947,59 +6068,38 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- /// Close a pane; its rows go to `to`, or where layout.absorbVWeight - /// sends them when that is null. Focus on it follows its rows: to the - /// pane that grew into them, or to the scratch left standing in an - /// emptied column. Every route that closes a pane comes through here or - /// removeColumn, so none picks focus for itself, and none by recency. + /// sends them when that is null. Focus on it follows its rows to the + /// pane that grew into them. A column outlives its panes, as acme's + /// does (cols.c:186): closing its last one leaves it empty, the keyboard + /// on its tag; only Delcol takes a column away. The session's last pane + /// closing ends the session. Every route that closes a pane comes + /// through here or removeColumn, so none picks focus for itself, and + /// none by recency. pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void { const pane = p.panes[id] orelse return; - // A column outlives its panes. Closing the last one leaves an empty - // pane standing in its place and in its directory; only Delcol ever - // takes a column away. const place = layout.findPane(p, id); - const last_in_column = if (place) |f| p.col_n[f.col] == 1 else false; - var dir_buf: [limits.host_path_cap]u8 = undefined; - var dir: []const u8 = ""; - if (last_in_column) { - const had = paneDir(pane); - const n = @min(had.len, dir_buf.len); - @memcpy(dir_buf[0..n], had[0..n]); - dir = dir_buf[0..n]; - } + const emptied = if (place) |f| (if (p.col_n[f.col] == 1) f.col else null) else null; // Before anything is torn down, so a failure here changes nothing. try p.deinitPane(pane); - // ...and after it, so the stand-in inherits none of the closing pane. - const heir: ?usize = heir: { - if (!last_in_column) break :heir null; - const f = place.?; - const free = p.freeSlot() orelse break :heir null; - _ = p.newScratchIn(free, dir) catch break :heir null; - layout.insert(p, f.col, f.idx + 1, free); - break :heir free; - }; const gainer = layout.absorbVWeight(p, id, to); - const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null; + const beside = if (emptied) |c| p.besideColumn(c, p.rects[id]) else null; layout.removePane(p, id); p.panes[id] = null; - if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse { - p.quit = true; - p.emit(.quit); - return; - }; + if (p.active != id) return; + p.active = gainer orelse beside orelse p.anyPane() orelse return p.quitEmpty(); + if (emptied) |c| if (!p.header_focus) tagline.enterHeader(p, c); } - pub fn removeColumn(p: *Pardes, id: usize) !void { - const place = layout.findPane(p, id) orelse return; - const had_focus = layout.findPane(p, p.active); - const beside = if (had_focus != null and had_focus.?.col == place.col) - p.besideColumn(place.col, p.active) - else - null; + /// Delcol: close column `col` and every pane in it. + pub fn removeColumn(p: *Pardes, col: usize) !void { + if (col >= p.ncol) return; + const had_focus = p.keyboardColumn(p.active) == col; + const beside = if (had_focus) p.besideColumn(col, if (p.header_focus) null else p.rects[p.active]) else null; var ids: [MAX_PANES]usize = undefined; var parents: [MAX_PANES]*Pane = undefined; - const count = p.col_n[place.col]; + const count = p.col_n[col]; for (0..count) |i| { - ids[i] = p.col_panes[place.col][i]; + ids[i] = p.col_panes[col][i]; parents[i] = p.panes[ids[i]].?; } try p.detachCwds(parents[0..count]); @@ -6008,11 +6108,14 @@ pub const Pardes = struct { layout.removePane(p, closed); p.panes[closed] = null; } - if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse { - p.quit = true; - p.emit(.quit); - return; - }; + layout.dropColumn(p, col); + if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return p.quitEmpty(); + } + + /// No pane is left: the session is over. + fn quitEmpty(p: *Pardes) void { + p.quit = true; + p.emit(.quit); } /// The pane Last should fall back to when the jumplist has nothing: the @@ -6031,14 +6134,13 @@ pub const Pardes = struct { return null; } - /// The pane that takes over `id`'s place when column `col` goes: in the - /// column layout.removePane gives the width to, the one level with the - /// middle of `id`. Asked before the column goes, while rects still hold. - fn besideColumn(p: *const Pardes, col: usize, id: usize) ?usize { + /// The pane that takes over the keyboard from column `col`: in the + /// column layout.dropColumn gives the width to, the one level with the + /// middle of `r` (of the screen without one). Asked while rects hold. + fn besideColumn(p: *const Pardes, col: usize, r: ?Rect) ?usize { if (p.ncol < 2) return null; const next = if (col > 0) col - 1 else col + 1; - const r = p.rects[id]; - const mid = r.y + r.h / 2; + const mid = if (r) |at| at.y + at.h / 2 else p.screen_h / 2; var pick: ?usize = null; for (p.col_panes[next][0..p.col_n[next]]) |k| { if (p.panes[k] == null) continue; @@ -6648,43 +6750,67 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro try std.testing.expect(pane.prompt == .none and pane.input.own == null); } -test "a stillborn pane gives its column to the first pane that joins it" { +test "Newcol makes an empty column, and the next pane made from its tag fills it" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); defer p.deinit(); - try std.testing.expect(p.executeBuiltinLine(0, "Newcol")); - const blank = p.active; - p.sync(); // Tty splits by the rects a frame has laid out - try std.testing.expect(p.executeBuiltinLine(blank, "Tty")); + const first = p.active; + const panes_before = p.col_n[0]; + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); + try std.testing.expectEqual(panes_before, p.col_n[0]); + // The keyboard is on the new column's tag; no pane has it. + try std.testing.expect(p.header_focus and p.header_column.? == 1); + try std.testing.expectEqual(first, p.active); + p.sync(); + try std.testing.expect(p.executeBuiltinLine(first, "Tty")); const shell = p.active; - p.update(.tick); // the step ends: the placeholder goes - try std.testing.expect(p.panes[blank] == null); - try std.testing.expectEqual(@as(usize, 1), p.col_n[layout.findPane(p, shell).?.col]); - try std.testing.expectEqual(shell, p.active); + try std.testing.expect(shell != first); + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + try std.testing.expectEqual(shell, p.col_panes[1][0]); + try std.testing.expect(!p.header_focus); - // Typed into, body or tag, it is a pane like any other. - try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); - const typed = p.active; + // The next Tty splits as it always has: nothing is empty any more. p.sync(); - for ("ix") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); - try std.testing.expect(p.executeBuiltinLine(typed, "Tty")); - p.update(.tick); - try std.testing.expect(p.panes[typed] != null); - try std.testing.expectEqual(@as(usize, 2), p.col_n[layout.findPane(p, typed).?.col]); - - try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); - const tagged = p.active; + try std.testing.expect(p.executeBuiltinLine(shell, "Tty")); + try std.testing.expectEqual(@as(usize, 2), p.col_n[1]); +} + +test "a column tag's command acts on that column" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); + defer p.deinit(); + const first = p.active; + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expectEqual(@as(usize, 3), p.ncol); p.sync(); - try p.panes[tagged].?.appendTag(" make"); - try std.testing.expect(p.executeBuiltinLine(tagged, "Tty")); - p.update(.tick); - try std.testing.expect(p.panes[tagged] != null); + // New from the middle column's tag lands in it, not below `first`. + p.exec_column = 1; + try std.testing.expect(p.executeBuiltinLine(first, "New")); + p.exec_column = null; + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + try std.testing.expectEqual(p.active, p.col_panes[1][0]); + // Delcol from the empty right column's tag closes that one. + p.exec_column = 2; + try std.testing.expect(p.executeBuiltinLine(first, "Delcol")); + p.exec_column = null; + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + // A pane's own Delcol closes its column whatever tag has the keyboard. + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expect(p.header_focus); + try std.testing.expect(p.executeBuiltinLine(p.col_panes[1][0], "Delcol")); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); } test "Del from a key asks which neighbour takes the rows; nothing else asks" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 40 }); defer p.deinit(); + p.settings.placement = .pardes; // the neighbours below are what is under test var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const top = p.active; @@ -6781,7 +6907,8 @@ test "focus on a closed pane goes to the pane that takes its place, not the one const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); defer p.deinit(); const shell = p.active; - p.newScratchColumn(shell); + p.newColumn(shell); + p.newScratchIn(shell, p.activeColumn().?); const right = p.active; p.sync(); p.newScratchBelow(shell); @@ -6813,45 +6940,50 @@ test "focus on a closed pane goes to the pane that takes its place, not the one p.sync(); p.active = left_low; p.sync(); - try p.removeColumn(left_low); + try p.removeColumn(layout.findPane(p, left_low).?.col); p.sync(); try std.testing.expectEqual(right_low, p.active); } -test "closing the last pane in a column leaves an empty one in its place" { +test "closing the last pane in a column leaves the column empty, its tag focused" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); defer p.deinit(); const shell = p.active; - p.newScratchColumn(shell); + p.newColumn(shell); + p.newScratchIn(shell, p.activeColumn().?); const lone = p.active; try std.testing.expect(lone != shell); try std.testing.expectEqual(@as(usize, 2), p.ncol); const at = layout.findPane(p, lone).?; try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]); - const had = try std.testing.allocator.dupe(u8, p.panes[lone].?.cwdSlice()); - defer std.testing.allocator.free(had); + p.sync(); try p.removePane(lone, null); p.sync(); - // The column outlives the pane: still two columns, and the one that was - // emptied holds a fresh empty pane in the same directory. + // The column outlives the pane, as acme's does: still two columns, the + // emptied one with no pane and the keyboard on its tag. try std.testing.expectEqual(@as(usize, 2), p.ncol); - try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]); - const heir = p.col_panes[at.col][0]; - try std.testing.expect(heir != lone); + try std.testing.expectEqual(@as(usize, 0), p.col_n[at.col]); try std.testing.expect(p.panes[lone] == null); - const hp = p.panes[heir].?; - try std.testing.expectEqualStrings("", hp.file.?.content); - try std.testing.expectEqualStrings(had, hp.cwdSlice()); - try std.testing.expectEqual(heir, p.active); + try std.testing.expect(p.header_focus and p.header_column.? == at.col); + try std.testing.expectEqual(shell, p.active); try std.testing.expect(!p.quit); + // Down from it goes nowhere: nothing is below. + try std.testing.expect(p.executeBuiltinLine(shell, "Down")); + try std.testing.expect(p.header_focus and p.header_column.? == at.col); + // ...and Delcol is what actually takes the column away. - try p.removeColumn(heir); + try p.removeColumn(at.col); p.sync(); try std.testing.expectEqual(@as(usize, 1), p.ncol); + try std.testing.expectEqual(shell, p.active); + + // The session's last pane closing ends it. + try p.removePane(shell, null); + try std.testing.expect(p.quit); } test "the bare tty startup opens a text pane under the shell, unfocused" { @@ -7017,6 +7149,7 @@ test "hopping between two panes does not grow the jump stack" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); + p.settings.placement = .pardes; // two panes of room, not acme's fit to the doc's two lines defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 13c02231..58dd2c3c 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -2045,7 +2045,7 @@ pub fn openSections(core: *pardes.Pardes, id: usize) void { .serial = result.serial, .revision = result.file.?.revision, }; - exec.placeDoc(core, id, free, result); + exec.placeNew(core, id, id, free, .doc); layout.compute(core); core.active = id; pane.search_pane = free; diff --git a/src/surface.zig b/src/surface.zig index b6f41853..a0471e51 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -397,7 +397,9 @@ pub const Chrome = struct { /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. pub const Surface = struct { - pub const PointerShape = enum(u8) { arrow, link, target }; + /// `box` is acme's boxcursor, shown while a pane or a column is carried + /// by its grip (cols.c:485, rows.c:151). + pub const PointerShape = enum(u8) { arrow, link, target, box }; pointer_shape: PointerShape = .arrow, body_layers: [MAX_PANES]Layer = @splat(.{}), tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}), diff --git a/src/tagline.zig b/src/tagline.zig index 59c51b4d..c04cbc66 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -321,6 +321,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void { if (p.panes[p.active]) |pane| pane.focus = .body; p.header_focus = true; p.header_column = column; + if (column) |c| p.setActiveColumn(c); const t = focusedHeader(p) orelse return exitHeader(p); const text = edit.flatSurface(p, t) catch ""; const rows = modal.cursorLineCount(text); diff --git a/src/web/app.mjs b/src/web/app.mjs index 03829178..c053d663 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -42,7 +42,8 @@ export class DomRenderer { render() { const count = this.wasm.pardes_frame(); - this.root.style.cursor = this.wasm.pardes_pointer_shape() === 1 ? "pointer" : "text"; + const shape = this.wasm.pardes_pointer_shape(); + this.root.style.cursor = shape === 1 ? "pointer" : shape === 3 ? "move" : "text"; const cols = this.wasm.pardes_frame_cols(); const rows = this.wasm.pardes_frame_rows(); if (count !== cols * rows) throw new Error(`bad frame: ${count} cells for ${cols}x${rows}`); diff --git a/test/appearance_gallery.py b/test/appearance_gallery.py index 48c0ea1b..bc9a7570 100644 --- a/test/appearance_gallery.py +++ b/test/appearance_gallery.py @@ -78,8 +78,8 @@ def run(args): launch=launch, inherited=inherited, terminal=handles) as (client, _): source = next(serial for serial, tag in panes(client).items() if str(source_path) in tag) before = set(panes(client)) - command(client, source, 'Newcol') - right = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'right column missing') + command(client, source, 'Newcol') # empty, its tag with the keyboard + right = int(client.read('/pane/new')) client.write(f'/pane/{right}/name', f'{root / "Review"}\n'.encode()) client.write(f'/pane/{right}/body', PREFERENCE_NOTES if args.theme_selector else NOTES, truncate=True) client.write(f'/pane/{right}/dirty', b'0\n') diff --git a/test/column_drag.zig b/test/column_drag.zig index 3aea4286..89508800 100644 --- a/test/column_drag.zig +++ b/test/column_drag.zig @@ -46,7 +46,6 @@ test "column grip drag moves only its interval and keeps tag caret with the colu defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; for (0..3) |_| { try std.testing.expect(p.executeBuiltinLine(p.active, "New")); @@ -127,7 +126,6 @@ test "column grip is separate from compact text and the resize seam" { defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; try std.testing.expect(p.executeBuiltinLine(0, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); @@ -181,7 +179,6 @@ test "column grips stay blank, and muted across focus and hover until grabbed" { defer p.deinit(); _ = try p.setTestFile("left\n"); p.presentation.enabled = false; - p.settings.column_tags = true; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; try std.testing.expect(p.executeBuiltinLine(0, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); @@ -219,7 +216,6 @@ test "column grip dropped in its own place moves the column's left edge" { defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; try std.testing.expect(p.executeBuiltinLine(0, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); p.update(.tick); @@ -284,7 +280,6 @@ test "column grip drag previews where the column lands, in the seam beside its r defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; for (0..2) |_| { try std.testing.expect(p.executeBuiltinLine(p.active, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); diff --git a/test/column_tags.py b/test/column_tags.py index fe3a8699..ba638b7d 100644 --- a/test/column_tags.py +++ b/test/column_tags.py @@ -138,7 +138,10 @@ def run(args): before = panes(client) keys.click(*require(locate(client, 'Newcol', row=0), 'Newcol missing'), button=1) - right = require(wait_for(lambda: new_serial(client, before)), 'Newcol did not create its pane') + # Newcol makes an empty column and gives its tag the keyboard; the + # pane made next fills it. + require(wait_for(lambda: client.read('/focus').strip() == b''), 'Newcol did not focus its empty column') + right = int(client.read('/pane/new')) right_name = root / 'right.rs' client.write(f'/pane/{right}/name', f'{right_name}\n'.encode()) right_body = b'// Column-local scratch\nfn main() {\n let ready = true;\n}\n' diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index 38ca89e5..8e859fdc 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -8,11 +8,10 @@ 08-wide-glyph-edges 123a182b82a2a8c455291a5c89ccfa28e1f8ce9566172ca404c0b9c71dd33b48 09-collapsed-focus-tint ae66076fdfb0cab9a906b052df257deee5ba91a6274712d52a76d8a7adc5b415 10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 -11-column-tags 6b1731d1611404597c96ad77f34c2d5cd5a81c1085ec68c6779388e07ce28614 -12-multiline-tag 1b9ccd331814dc76111470f18448b1ca192e1edc3d79ea6eee19a6a78a4b1831 -13-image a52e2cf9f238447d885692cba01e68ef7fdc6ae3fc7c8fe1e95c8463c85f519c -14-theme-ink cc2ea256ddda24e1c97871f75b71ddf62fa825bd02f5d1432592d05e478c707a -15-theme-acme-light 2bde8a2bdfada7f061217f52cb2272913fc0657dbfa1d7448e1852f321f8e3bf +12-multiline-tag 0edffadbabef48e5a7422ef8433ccf2552b9c812a352e30b32bb000aacad607d +13-image 91f9363493c9c1b9618426ef7362da469f5b051890a3128ef8c94cee6ae2bb06 +14-theme-ink 84440553176db695a9a8a82714e858c815ea31cd82a29760c4894b4675c3a2ed +15-theme-acme-light cd216282f477512284372f330709d0cde93d1f2fb6d9c4616ebb642f3ef5c129 16-debug 129ad6f5f85b0e60e81bc6e8e78434629ee751f49731cf2eb28fd0553efd914a -17-terminal 45a3d9f823ccf28a336ef6687c9c210f9fb05cc426c0fdee98bb0ebb2a957d42 -18-mid-transition 79f90fb247f75ab284fca147028ee18ea78e77a02a57203699fb423f4bcc02fb +17-terminal d958ca0182ddf360abd266e29c3934c6e251ed25f258f687597372369899eaea +18-mid-transition a35f460c8637c2183f37b3a5ac2e7bd34bdde708a52fa0f8dcdbf937627b125f diff --git a/test/gui_golden.py b/test/gui_golden.py index d97009c9..407b19cd 100644 --- a/test/gui_golden.py +++ b/test/gui_golden.py @@ -91,7 +91,7 @@ def run(args): config.mkdir(parents=True) # No linger: a dismissed message is gone, not caught part way, when a # scene settles. - (config / 'init').write_text('Shell /bin/sh\nTheme forge\nMessageLinger 0\n') + (config / 'init').write_text('Shell /bin/sh\nTheme forge\nMessageLinger 0\nPlacement pardes\n') source = root / 'shapes.zig' source.write_bytes(ZIG) notes = root / 'notes.txt' @@ -177,9 +177,6 @@ def run(args): client.write('/ctl', b'Tagbottom\n') scene('10-tagbottom') client.write('/ctl', b'Tagbottom\n') - client.write('/ctl', b'ColumnTags\n') - scene('11-column-tags') - client.write('/ctl', b'ColumnTags\n') client.write(f'/pane/{first}/tag', b' Second\nthird line of the tag') scene('12-multiline-tag') client.write(f'/pane/{first}/look', f'{image}\n'.encode()) diff --git a/test/panes.zig b/test/panes.zig index 40b4b6ba..9802432e 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -103,7 +103,6 @@ const TagLayerTests = struct { defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; try std.testing.expect(p.executeBuiltinLine(0, "New")); const second = p.active; diff --git a/test/pdf.zig b/test/pdf.zig index 2699fe01..4cb8f3dd 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -343,7 +343,7 @@ test "PdfSections Look follows the exact owning PDF, not an equal path" { const duplicate_id = p.freeSlot() orelse return error.NoDuplicatePdfSlot; const duplicate = try panes.Pdf.openPane(p, duplicate_id, path, 0); - pardes.exec.placeDoc(p, 0, duplicate_id, duplicate); + pardes.exec.placeNew(p, 0, 0, duplicate_id, .doc); layout.compute(p); panes.Pdf.openSections(p, duplicate_id); const duplicate_output_id = duplicate.search_pane orelse return error.MissingDuplicatePdfSections; diff --git a/test/snapshots/argv.snap b/test/snapshots/argv.snap index 892f6b24..0d4c8f64 100644 --- a/test/snapshots/argv.snap +++ b/test/snapshots/argv.snap @@ -4,6 +4,8 @@ # argument becomes the cwd the shell spawns in lines notes.txt 8 file zmarker.txt x +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 wait 8000 $ stable 700 20000 diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap index 8bf70f36..7810ebda 100644 --- a/test/snapshots/builtins.snap +++ b/test/snapshots/builtins.snap @@ -1,4 +1,6 @@ lines notes.txt 6 +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 67 120 notes.txt wait 8000 notes.txt stable 700 20000 diff --git a/test/snapshots/chordcut.snap b/test/snapshots/chordcut.snap index 56170cce..01fee1f7 100644 --- a/test/snapshots/chordcut.snap +++ b/test/snapshots/chordcut.snap @@ -11,6 +11,8 @@ lines g.txt 2 # unrelated right column untouched. Give the caller enough cells that the selected # `echo B''OO g.txt` remains one displayed row; this test is about chords, not # soft-wrap traversal. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 120 -n 3 wait 8000 f.txt stable 700 20000 diff --git a/test/snapshots/cmdword.snap b/test/snapshots/cmdword.snap index 631fab8b..438d3ed5 100644 --- a/test/snapshots/cmdword.snap +++ b/test/snapshots/cmdword.snap @@ -23,6 +23,8 @@ file zzmark.txt x # the top of the body -- which would move every row this script clicks. The # verbs are what is under test here, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 120 cmds.txt wait 8000 Newcol Joincol wait 8000 cmds.txt diff --git a/test/snapshots/delask.snap b/test/snapshots/delask.snap index 04bc850c..05f4b204 100644 --- a/test/snapshots/delask.snap +++ b/test/snapshots/delask.snap @@ -2,6 +2,8 @@ # pane's notice band, which one takes its rows: k above, j below, anything else # keeps the pane. A click, and DelAbove/DelBelow (Del k / Del j), never ask. file top.txt top-marker +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 36 100 top.txt wait 8000 top-marker stable 700 20000 diff --git a/test/snapshots/doccol.snap b/test/snapshots/doccol.snap index 2135c533..7855453c 100644 --- a/test/snapshots/doccol.snap +++ b/test/snapshots/doccol.snap @@ -17,6 +17,8 @@ file c.txt CCC # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 a.txt wait 8000 AAA stable 700 20000 diff --git a/test/snapshots/emptycol.golden b/test/snapshots/emptycol.golden new file mode 100644 index 00000000..bb41c028 --- /dev/null +++ b/test/snapshots/emptycol.golden @@ -0,0 +1,14 @@ +== snap newcol grid=100x24 cursor=53,1 +|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit +| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol +| /tmp/pardes-snap/emptycol/cwd/notes.txt Save Tt +| 1 one +| 2 two +== snap filled grid=100x24 cursor=54,5 +|2: /tmp/pardes-snap/emptycol/cwd/notes.txt Save Tt /tmp/pardes-snap/emptycol/cwd Tty+bash Save Mod +|3: 1 one ls +|4: 2 two notes.txt +== snap emptied grid=100x24 cursor=53,1 +|2: /tmp/pardes-snap/emptycol/cwd/notes.txt Save Tt +|3: 1 one +|4: 2 two diff --git a/test/snapshots/emptycol.snap b/test/snapshots/emptycol.snap new file mode 100644 index 00000000..edf61eed --- /dev/null +++ b/test/snapshots/emptycol.snap @@ -0,0 +1,22 @@ +# A column can hold no pane, as acme's can (cols.c:186). Newcol makes one +# empty, its tag over blank space and holding the keyboard; the next pane +# made while it is the active column fills it whole (makenewwindow, +# util.c:468-469); and a shell that exits there leaves it empty again, the +# keyboard back on its tag. +file notes.txt one\ntwo +config Verbose off +start 24 100 notes.txt +wait 8000 notes.txt +stable 700 20000 +key space c n +stable 400 5000 +snap newcol +key a-n +stable 700 15000 +snap filled +key c-b +stable 400 5000 +text exit +key enter +stable 700 10000 +snap emptied diff --git a/test/snapshots/filewatch.snap b/test/snapshots/filewatch.snap index 38b6ac8e..1579ad40 100644 --- a/test/snapshots/filewatch.snap +++ b/test/snapshots/filewatch.snap @@ -22,6 +22,8 @@ lines sub/s.txt 8 # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 210 -n 1 wait 8000 w.txt stable 700 20000 diff --git a/test/snapshots/find.snap b/test/snapshots/find.snap index fe1ecad3..82c68343 100644 --- a/test/snapshots/find.snap +++ b/test/snapshots/find.snap @@ -17,6 +17,8 @@ file sub/ZQMARK-beta.txt beta body file sub/nest/zqmark-gamma.txt gamma body file other.txt nothing to match here file .git/zqmark-hidden.txt hidden body +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 41 120 here.txt wait 8000 here.txt stable 700 20000 diff --git a/test/snapshots/fsearch.snap b/test/snapshots/fsearch.snap index b050f1db..aff3e37f 100644 --- a/test/snapshots/fsearch.snap +++ b/test/snapshots/fsearch.snap @@ -13,6 +13,8 @@ file g.txt l1\nMARK a\nl3\nl4\nl5\nMARK b\nl7\nl8\nl9\nMARK c\nl11\nl12 # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 180 -n 3 wait 8000 g.txt stable 700 20000 diff --git a/test/snapshots/grep.snap b/test/snapshots/grep.snap index 07777e67..207aedc6 100644 --- a/test/snapshots/grep.snap +++ b/test/snapshots/grep.snap @@ -21,6 +21,8 @@ config Verbose off # The stacked layout spends two rows on each hit, which puts the third one # below the fold of this results pane; the claim here is which hits there are. config LocationsConfig layout:inline +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 180 -n 3 wait 8000 a.txt stable 700 20000 diff --git a/test/snapshots/jumps.snap b/test/snapshots/jumps.snap index e4816f88..74dc5fd7 100644 --- a/test/snapshots/jumps.snap +++ b/test/snapshots/jumps.snap @@ -10,6 +10,8 @@ # Focus and position both show in the golden's `cursor=` header: that is the # ACTIVE pane's cursor, so each snap is named by where it should land. lines big.txt 200 +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 150 big.txt wait 8000 big.txt stable 700 20000 diff --git a/test/snapshots/layout-close.golden b/test/snapshots/layout-close.golden index a7d159b5..38985b40 100644 --- a/test/snapshots/layout-close.golden +++ b/test/snapshots/layout-close.golden @@ -43,6 +43,7 @@ |10: |11: |12: -== snap colclose grid=140x41 cursor=42,3 -|3: ls 1 ls +== snap colclose grid=140x41 cursor=38,1 +|2: /tmp/pardes-snap/layout-close/cw /tmp/pardes-snap/layout-close/cwd Tty+bash Save Mode Filter Collaps +|3: ls ls |4: marker.txt marker.txt diff --git a/test/snapshots/layout-close.snap b/test/snapshots/layout-close.snap index 4ae3e31f..b168eb2b 100644 --- a/test/snapshots/layout-close.snap +++ b/test/snapshots/layout-close.snap @@ -1,7 +1,9 @@ # every pane close hands its space to ONE sibling, the rest of the column # keeps its sizes: Alt-c move-out, tag Del, and shell exit (pty EOF); an -# emptied column hands its width to one neighbor +# emptied column stays, as acme's does, its tag over blank space file marker.txt x +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 41 140 -n 3 wait 8000 marker.txt stable 700 20000 @@ -49,8 +51,8 @@ text exit key enter stable 700 10000 snap exit -# exit the moved-out pane's shell: its column empties and returns its width to -# the source column it was carved from; the untouched right column stays put +# exit the moved-out pane's shell: its column stays where it was, empty, the +# keyboard on its tag; the other columns keep their widths press left 40 9 release left 40 9 stable 400 5000 diff --git a/test/snapshots/layout-open.snap b/test/snapshots/layout-open.snap index 04770f27..c67dcfa5 100644 --- a/test/snapshots/layout-open.snap +++ b/test/snapshots/layout-open.snap @@ -5,6 +5,8 @@ # unrelated pane (including the ones above the source) at its exact rectangle. dirmk sub file sub/f.txt echo 'TTY'OK +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 -n 3 wait 8000 sub stable 700 20000 diff --git a/test/snapshots/leader.snap b/test/snapshots/leader.snap index 78594a47..e8f42331 100644 --- a/test/snapshots/leader.snap +++ b/test/snapshots/leader.snap @@ -1,4 +1,6 @@ file cmds.txt Help +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 41 100 cmds.txt wait 8000 Newcol Joincol wait 8000 cmds.txt diff --git a/test/snapshots/lookpanes.snap b/test/snapshots/lookpanes.snap index 8ada3f1b..296570f7 100644 --- a/test/snapshots/lookpanes.snap +++ b/test/snapshots/lookpanes.snap @@ -24,6 +24,8 @@ file beta/bmark.txt both.txt\nb-marker # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 51 130 beta/bmark.txt wait 8000 b-marker stable 700 20000 diff --git a/test/snapshots/lookrange.snap b/test/snapshots/lookrange.snap index 50ef9ad3..ef04409e 100644 --- a/test/snapshots/lookrange.snap +++ b/test/snapshots/lookrange.snap @@ -18,6 +18,8 @@ file look.txt r.txt:3-5\nr.txt:7:4-9\nr.txt:3:2-5:3\nr.txt:8:6-99:9 # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 150 look.txt wait 8000 r.txt:3-5 stable 700 20000 diff --git a/test/snapshots/lsp-client.snap b/test/snapshots/lsp-client.snap index 541e7c7f..7b255bd8 100644 --- a/test/snapshots/lsp-client.snap +++ b/test/snapshots/lsp-client.snap @@ -9,6 +9,8 @@ # gr -> sorted rows, selecting the next occurrence; n/N continue there. file main.rs mod lib;\n\nfn main() {\n let x = add(1, 2);\n let y = add(3, 4);\n let _ = x + y;\n}\n file lib.rs // mock fixture\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 25 100 main.rs wait 8000 add(1, 2) stable 700 20000 diff --git a/test/snapshots/lsp.snap b/test/snapshots/lsp.snap index b40484ee..68365527 100644 --- a/test/snapshots/lsp.snap +++ b/test/snapshots/lsp.snap @@ -11,6 +11,8 @@ # symbol from std — so these goldens do not encode this machine's stdlib # version or its install path. file a.zig const std = @import("std");\n\nfn helper(x: u32) u32 {\n return x + 1;\n}\n\npub fn main() void {\n _ = helper(41);\n}\n +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 41 120 a.zig wait 8000 helper stable 700 20000 diff --git a/test/snapshots/lspcomplete.snap b/test/snapshots/lspcomplete.snap index 53ce7ef9..dcdec45c 100644 --- a/test/snapshots/lspcomplete.snap +++ b/test/snapshots/lspcomplete.snap @@ -18,6 +18,8 @@ # script the only end-to-end coverage the feature has. file a.zig const Shade = enum {\n verdigris,\n cinnabar,\n};\n\nfn pick(s: Shade) u8 {\n return switch (s) {\n \n };\n}\n\n// note. see below\n file notes.txt abc.def ghi\n +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 41 120 a.zig wait 8000 verdigris stable 700 20000 diff --git a/test/snapshots/lsprelpath.snap b/test/snapshots/lsprelpath.snap index 247390fe..89b0e124 100644 --- a/test/snapshots/lsprelpath.snap +++ b/test/snapshots/lsprelpath.snap @@ -30,6 +30,8 @@ file sub/inner/tint.zig pub const Tint = enum {\n ochre,\n indigo,\n};\n file sub/parent.zig const shade = @import("../shade.zig");\n\npub fn pick(s: shade.Shade) u8 {\n return switch (s) {\n \n };\n}\n file sub/subdir.zig const tint = @import("inner/tint.zig");\n\npub fn hue(t: tint.Tint) u8 {\n return switch (t) {\n \n };\n}\n # ---- PARENT: outside the asking file's tree, so the row stays absolute ---- +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 35 100 sub/parent.zig wait 8000 fn pick stable 700 20000 diff --git a/test/snapshots/mini.snap b/test/snapshots/mini.snap index 4905c68d..ffa9d94e 100644 --- a/test/snapshots/mini.snap +++ b/test/snapshots/mini.snap @@ -1,5 +1,7 @@ file commands.txt @`Mini demo.zig` file demo.zig const greeting = "café λ 界 é";\n\npub fn choose(ok: bool) u32 {\n if (ok) return 42;\n return 7;\n}\n\n// space and color survive compression\nconst answer = choose(true);\n +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 23 90 commands.txt wait 8000 Mini demo.zig stable 400 10000 diff --git a/test/snapshots/nested.snap b/test/snapshots/nested.snap index c26b7cea..9c31fb00 100644 --- a/test/snapshots/nested.snap +++ b/test/snapshots/nested.snap @@ -1,6 +1,8 @@ dirmk nestdir file nestdir/inside-nested.txt marker file nested.txt hello-from-nested\nsecond-nested-line\n +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 wait 8000 $ stable 700 20000 diff --git a/test/snapshots/psearch.snap b/test/snapshots/psearch.snap index a46e2b9d..f32fdf75 100644 --- a/test/snapshots/psearch.snap +++ b/test/snapshots/psearch.snap @@ -12,6 +12,8 @@ # It splits BELOW the shell it came from: every snap below stays ONE column, # the shell's tag and the +Search tag both starting at x=0, thirty rows split # between them, and nothing shifts sideways. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 140 wait 8000 Newcol Joincol stable 700 20000 diff --git a/test/snapshots/reflow.snap b/test/snapshots/reflow.snap index 7aac1e8c..4a2a5368 100644 --- a/test/snapshots/reflow.snap +++ b/test/snapshots/reflow.snap @@ -4,6 +4,8 @@ # integrity check panicked the whole app ("ViewportPinInsufficientRows"). # build.zig now pins the ghostty dependency at ReleaseSafe so those # development-only checks are off — the app's own safety checks are unchanged. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 25 40 wait 8000 $ text printf 'y%.0s' $(seq 1 3000); echo do''ne diff --git a/test/snapshots/rsearch.snap b/test/snapshots/rsearch.snap index 30459488..a83d27c4 100644 --- a/test/snapshots/rsearch.snap +++ b/test/snapshots/rsearch.snap @@ -11,6 +11,8 @@ file code.txt run count_x now\nopen sub/dep.txt:3 here\ncount_x again\npick two # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 210 -n 1 wait 8000 code.txt stable 700 20000 diff --git a/test/snapshots/savepane.snap b/test/snapshots/savepane.snap index 58df13ef..39129347 100644 --- a/test/snapshots/savepane.snap +++ b/test/snapshots/savepane.snap @@ -7,6 +7,8 @@ # scratch asks for a path in its inherited dir and makes it a file"); none of # them can see it drawn, land on the tag rather than the message row, or show # what the pane becomes afterwards. That is what this script is for. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 -n 1 wait 8000 Newcol Joincol stable 700 20000 diff --git a/test/snapshots/scrolloff.snap b/test/snapshots/scrolloff.snap index caed9d47..f99c7341 100644 --- a/test/snapshots/scrolloff.snap +++ b/test/snapshots/scrolloff.snap @@ -1,6 +1,8 @@ # scrolloff: keyboard motion keeps 3 context rows above/below the cursor, # clamped at file boundaries (cursor may reach the top/bottom body row there) lines g.txt 40 +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 12 210 -n 1 wait 8000 g.txt stable 700 20000 diff --git a/test/snapshots/splitparent.snap b/test/snapshots/splitparent.snap index 1fd4e935..e5595144 100644 --- a/test/snapshots/splitparent.snap +++ b/test/snapshots/splitparent.snap @@ -1,6 +1,8 @@ # a new pane never opens under a too-short parent: Alt-n from a tag-only pane # splits the tall sibling in its column instead; when NO pane could leave the # newcomer 2 body rows, the tallest one anywhere is split +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 -n 3 stable 700 20000 # shrink pane 0 (top-left) to just its tagline diff --git a/test/snapshots/stepgrain.snap b/test/snapshots/stepgrain.snap index 3c4d3e4c..323fda5c 100644 --- a/test/snapshots/stepgrain.snap +++ b/test/snapshots/stepgrain.snap @@ -21,6 +21,8 @@ dirmk sub file a.txt hello ZQMARK one\nsee also sub/b.txt for ZQMARK two file sub/b.txt ZQMARK inside sub run touch 'two words.txt' +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 150 a.txt wait 8000 hello ZQMARK stable 700 20000 diff --git a/test/snapshots/tagalign.snap b/test/snapshots/tagalign.snap index a77415d9..7a0c93ce 100644 --- a/test/snapshots/tagalign.snap +++ b/test/snapshots/tagalign.snap @@ -2,6 +2,8 @@ dirmk deep dirmk deep/nested-project-directory file deep/nested-project-directory/document.txt alpha\nbeta\ngamma +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 deep/nested-project-directory/document.txt wait 8000 Newcol Joincol wait 8000 document.txt diff --git a/test/snapshots/tagalign2col.snap b/test/snapshots/tagalign2col.snap index 0849ffae..353289d4 100644 --- a/test/snapshots/tagalign2col.snap +++ b/test/snapshots/tagalign2col.snap @@ -2,6 +2,8 @@ dirmk deep dirmk deep/proj file deep/proj/document.txt alpha\nbeta\ngamma +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 160 deep/proj/document.txt wait 8000 Newcol Joincol wait 8000 document.txt diff --git a/test/snapshots/tagbottom.snap b/test/snapshots/tagbottom.snap index 67bb2ead..c294a7b6 100644 --- a/test/snapshots/tagbottom.snap +++ b/test/snapshots/tagbottom.snap @@ -21,6 +21,8 @@ file bottom.txt ZZONE\nZZTWO # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 top.txt wait 8000 Newcol Joincol wait 8000 top.txt diff --git a/test/snapshots/tagnav.snap b/test/snapshots/tagnav.snap index f145480e..a0e06cd0 100644 --- a/test/snapshots/tagnav.snap +++ b/test/snapshots/tagnav.snap @@ -10,6 +10,8 @@ # carries its BODY mode as one character: ` ` normal, `^` insert, `$` tty. The # tag has a mode of its own, so the box never changes while a tag is used. file w.txt alpha\nbeta\ngamma +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 w.txt wait 8000 Newcol Joincol wait 8000 w.txt diff --git a/test/snapshots/themesel.snap b/test/snapshots/themesel.snap index 04a95d23..734b9fdd 100644 --- a/test/snapshots/themesel.snap +++ b/test/snapshots/themesel.snap @@ -16,6 +16,8 @@ # read with snapstyle, so this proves the generated themes are real data and # not just names in a list. lines t.txt 40 +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 t.txt wait 8000 t.txt stable 700 20000 diff --git a/test/snapshots/tinywin.snap b/test/snapshots/tinywin.snap index 3d22d583..388609e4 100644 --- a/test/snapshots/tinywin.snap +++ b/test/snapshots/tinywin.snap @@ -2,6 +2,8 @@ # every pane in a column at least one row, so `y` walked off the bottom and # renderPane got a rect outside the surface (assert). Panes that no longer fit # now get h = 0 and simply do not render until the window grows back. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 wait 8000 $ key a-n diff --git a/test/snapshots/topbarexec.snap b/test/snapshots/topbarexec.snap index 516a23b3..9f62af46 100644 --- a/test/snapshots/topbarexec.snap +++ b/test/snapshots/topbarexec.snap @@ -14,6 +14,8 @@ # So both halves are pinned here, against the same live selection: the no-arg # builtin runs, and the arg-taking one still gets the word. file zmarkfile.txt hay ZQNEEDLE stack +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 21 90 -n 3 wait 8000 zmarkfile.txt stable 700 20000 diff --git a/test/snapshots/ttyhelp.snap b/test/snapshots/ttyhelp.snap index 24399848..59e73af2 100644 --- a/test/snapshots/ttyhelp.snap +++ b/test/snapshots/ttyhelp.snap @@ -1,6 +1,8 @@ # bare boot = tty mode: SPC belongs to the shell, so the topbar Help word is # the only route to the command list. Row 0 is not a pane, so a middle-click # there is dispatched before any pane's mode is consulted. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 wait 8000 Newcol Joincol stable 700 20000 diff --git a/test/snapshots/ttylook.snap b/test/snapshots/ttylook.snap index 5b82bd89..6eecc419 100644 --- a/test/snapshots/ttylook.snap +++ b/test/snapshots/ttylook.snap @@ -9,6 +9,8 @@ lines big.txt 40 file plain.txt just text dirmk sub +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 120 -n 3 wait 8000 big.txt stable 700 20000 diff --git a/test/snapshots/ttyonly.snap b/test/snapshots/ttyonly.snap index 1008e084..85180b40 100644 --- a/test/snapshots/ttyonly.snap +++ b/test/snapshots/ttyonly.snap @@ -1,5 +1,7 @@ # --tty: a single fullscreen shell already in raw tty mode, prompts visible file t.txt x +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 --tty wait 8000 $ stable 700 20000 diff --git a/test/snapshots/ttytaken.snap b/test/snapshots/ttytaken.snap index a6ccb632..be0bad29 100644 --- a/test/snapshots/ttytaken.snap +++ b/test/snapshots/ttytaken.snap @@ -6,6 +6,8 @@ # the pager's screen changes. (From any pane but a terminal, an Exec always # runs as a command pane: exec.snap and cmdword.snap.) lines big.txt 40 +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 210 -n 1 wait 8000 big.txt stable 700 20000 diff --git a/test/snapshots/windownav.snap b/test/snapshots/windownav.snap index 66509e11..fc26896f 100644 --- a/test/snapshots/windownav.snap +++ b/test/snapshots/windownav.snap @@ -8,6 +8,8 @@ # `cursor=` header: that is the # ACTIVE pane's cursor, so each snap is named by the pane it should land in. file w.txt alpha\nbeta\ngamma +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 w.txt wait 8000 Newcol Joincol wait 8000 w.txt -- cgit v1.3