diff options
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 @@ -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 |
