summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md9
-rw-r--r--docs/config.md8
-rw-r--r--docs/fs.md9
-rw-r--r--docs/helix-keys.md2
-rw-r--r--docs/tags.md68
-rw-r--r--docs/ui-review.md3
-rw-r--r--src/CHANGELOG.md16
-rw-r--r--src/Mini.zig2
-rw-r--r--src/Output.zig10
-rw-r--r--src/builtins.zig25
-rw-r--r--src/config.zig24
-rw-r--r--src/draw.zig7
-rw-r--r--src/dump.zig17
-rw-r--r--src/exec.zig160
-rw-r--r--src/fs-help.txt8
-rw-r--r--src/gui/gui.zig30
-rw-r--r--src/layout.zig238
-rw-r--r--src/look.zig17
-rw-r--r--src/mouse.zig11
-rw-r--r--src/ninep/ctl.zig19
-rw-r--r--src/ninep/pane.zig4
-rw-r--r--src/ninep/tree.zig2
-rw-r--r--src/pardes.zig457
-rw-r--r--src/pdf_view.zig2
-rw-r--r--src/surface.zig4
-rw-r--r--src/tagline.zig1
-rw-r--r--src/web/app.mjs3
-rw-r--r--test/appearance_gallery.py4
-rw-r--r--test/column_drag.zig5
-rw-r--r--test/column_tags.py5
-rw-r--r--test/gui-goldens.txt13
-rw-r--r--test/gui_golden.py5
-rw-r--r--test/panes.zig1
-rw-r--r--test/pdf.zig2
-rw-r--r--test/snapshots/argv.snap2
-rw-r--r--test/snapshots/builtins.snap2
-rw-r--r--test/snapshots/chordcut.snap2
-rw-r--r--test/snapshots/cmdword.snap2
-rw-r--r--test/snapshots/delask.snap2
-rw-r--r--test/snapshots/doccol.snap2
-rw-r--r--test/snapshots/emptycol.golden14
-rw-r--r--test/snapshots/emptycol.snap22
-rw-r--r--test/snapshots/filewatch.snap2
-rw-r--r--test/snapshots/find.snap2
-rw-r--r--test/snapshots/fsearch.snap2
-rw-r--r--test/snapshots/grep.snap2
-rw-r--r--test/snapshots/jumps.snap2
-rw-r--r--test/snapshots/layout-close.golden5
-rw-r--r--test/snapshots/layout-close.snap8
-rw-r--r--test/snapshots/layout-open.snap2
-rw-r--r--test/snapshots/leader.snap2
-rw-r--r--test/snapshots/lookpanes.snap2
-rw-r--r--test/snapshots/lookrange.snap2
-rw-r--r--test/snapshots/lsp-client.snap2
-rw-r--r--test/snapshots/lsp.snap2
-rw-r--r--test/snapshots/lspcomplete.snap2
-rw-r--r--test/snapshots/lsprelpath.snap2
-rw-r--r--test/snapshots/mini.snap2
-rw-r--r--test/snapshots/nested.snap2
-rw-r--r--test/snapshots/psearch.snap2
-rw-r--r--test/snapshots/reflow.snap2
-rw-r--r--test/snapshots/rsearch.snap2
-rw-r--r--test/snapshots/savepane.snap2
-rw-r--r--test/snapshots/scrolloff.snap2
-rw-r--r--test/snapshots/splitparent.snap2
-rw-r--r--test/snapshots/stepgrain.snap2
-rw-r--r--test/snapshots/tagalign.snap2
-rw-r--r--test/snapshots/tagalign2col.snap2
-rw-r--r--test/snapshots/tagbottom.snap2
-rw-r--r--test/snapshots/tagnav.snap2
-rw-r--r--test/snapshots/themesel.snap2
-rw-r--r--test/snapshots/tinywin.snap2
-rw-r--r--test/snapshots/topbarexec.snap2
-rw-r--r--test/snapshots/ttyhelp.snap2
-rw-r--r--test/snapshots/ttylook.snap2
-rw-r--r--test/snapshots/ttyonly.snap2
-rw-r--r--test/snapshots/ttytaken.snap2
-rw-r--r--test/snapshots/windownav.snap2
78 files changed, 933 insertions, 387 deletions
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, `<a>, <b>: 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/<n>/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 <dir>/+New), read names it;
- rmdir $m/pane/<n> closes it; closing a column's last pane leaves a +New there
+$m/pane/new open it to make a pane (a scratch named <dir>/+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/<n> 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/<n>/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-<pid or NAME>.sock`). A new pane made through `pane/new` is a scratch named
-`<dir>/+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).
+`<dir>/+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 <serial> <name>, run/exit/send <serial> ..., dump/restore <path>, 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/<n>/ 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, "Tty"));
+ try std.testing.expectEqual(@as(usize, 2), p.col_n[1]);
+}
- try std.testing.expect(p.executeBuiltinLine(shell, "Newcol"));
- const tagged = p.active;
+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