summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md544
-rw-r--r--README.md263
-rw-r--r--build.zig72
-rw-r--r--docs/cloud9.md139
-rw-r--r--docs/config.md790
-rw-r--r--docs/design.typ4
-rw-r--r--docs/detached.md76
-rw-r--r--docs/divergences.md101
-rw-r--r--docs/fs.md1688
-rw-r--r--docs/open-questions.md83
-rw-r--r--docs/render-pipeline.md2
-rw-r--r--docs/tags.md408
-rw-r--r--docs/v9fs.md130
-rw-r--r--src/9p.zig2
-rw-r--r--src/File.zig8
-rw-r--r--src/Layer.zig28
-rw-r--r--src/Messages.zig38
-rw-r--r--src/Output.zig2
-rw-r--r--src/ShaderBuild.zig (renamed from src/shader_build.zig)0
-rw-r--r--src/Text.zig18
-rw-r--r--src/body_layer.zig16
-rw-r--r--src/builtins.zig7
-rw-r--r--src/colors.zig121
-rw-r--r--src/detached/server.zig4
-rw-r--r--src/detached/wire.zig10
-rw-r--r--src/draw.zig62
-rw-r--r--src/dump.zig52
-rw-r--r--src/edit.zig30
-rw-r--r--src/exec.zig34
-rw-r--r--src/file_watch.zig2
-rw-r--r--src/fs.zig4
-rw-r--r--src/gui/Post.zig12
-rw-r--r--src/gui/gui.zig8
-rw-r--r--src/host_io.zig2
-rw-r--r--src/layout.zig4
-rw-r--r--src/locations.zig292
-rw-r--r--src/locations_cache.zig213
-rw-r--r--src/locations_config.zig75
-rw-r--r--src/look.zig8
-rw-r--r--src/main.zig42
-rw-r--r--src/mini.zig (renamed from src/Mini.zig)0
-rw-r--r--src/mouse.zig14
-rw-r--r--src/ninep/ctl.zig2
-rw-r--r--src/ninep/events.zig6
-rw-r--r--src/ninep/pane.zig10
-rw-r--r--src/ninep/pty.zig24
-rw-r--r--src/ninep/screen.zig4
-rw-r--r--src/ninep/tree.zig99
-rw-r--r--src/panes.zig24
-rw-r--r--src/pardes.zig124
-rw-r--r--src/tag_layer.zig32
-rw-r--r--src/terminal.zig (renamed from src/Terminal.zig)0
-rw-r--r--src/themes/acme.zig5
-rw-r--r--src/tty/tty.zig282
-rw-r--r--test/e2e_harness.zig28
-rw-r--r--test/fs.py47
-rw-r--r--test/gui-goldens.txt8
-rw-r--r--test/gui_monkey.py179
-rw-r--r--test/mode.zig2
-rw-r--r--test/monkey9p.py16
-rw-r--r--test/output.zig6
-rw-r--r--test/panes.zig68
-rw-r--r--test/perf-baseline-gui-Debug.json2
-rw-r--r--test/perf-baseline-tty-Debug.json2
-rw-r--r--test/perf-baseline-tty-ReleaseFast.json2
-rw-r--r--test/perf.zig26
-rw-r--r--test/snapshots/theme.golden12
-rw-r--r--test/snapshots/themesel.golden18
68 files changed, 2667 insertions, 3769 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 1d32ce7a..7cdca1a0 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -1,486 +1,174 @@
---
name: pardes-9p
-description: Inspect and drive a running Pardes editor through its control filesystem, or exercise its panes, builtins, terminal input and rendered output in an isolated session. Use for Pardes interaction, plugin development and end-to-end debugging.
+description: Inspect and drive a running Pardes editor through its 9P control filesystem, or exercise its panes, builtins, terminal input and rendered output in an isolated session. Use for Pardes interaction, plugin development and end-to-end debugging.
---
# Pardes over 9P
-Pardes is driven by reading and writing files. Prefer ordinary file tools over
-a mount; reach for the Python client only when nothing is mounted, or when the
-work needs a fid held open. Do not build another wire client: project tooling
-stays in Zig. Run the examples from the repository root; paths below are
-relative to that root unless linked.
+Pardes is files: `cat`, `echo >`, `ls` and shell scripts are the whole
+interface. [docs/fs.md](../../../docs/fs.md) is the reference for every
+file; this page is recipes. Paths are relative to the repository root.
-## Find the session through the mount
-
-Check `$NINE_MOUNT` first. `9ns --mntgen` sets it for every process it starts
-(an interactive shell on this machine runs inside one), so a set `$NINE_MOUNT`
-means the posted-9P registry is mounted there, usually `/mnt/9p`, and each
-running editor is a directory `$NINE_MOUNT/pardes/<pid>/` (a `--detach=NAME`
-session's is `pardes/NAME/`; `pardes --detach=NAME &` runs the editor as
-that process, so `$!` is its pid, and `$m/status`'s first line, `pid <n>`,
-says the same). Inside a pane, `$PARDES_9P` is that session's
-socket (`/run/user/1000/pardes-9p-<pid or NAME>.sock`), which names the
-directory either way, and `$PARDES_PANE` is the calling pane's serial:
+## Find the session
```sh
[ -n "$NINE_MOUNT" ] || echo 'no 9P mount: use the Python client below'
s=${PARDES_9P##*/pardes-9p-}; m=$NINE_MOUNT/pardes/${s%.sock}
-# under `9ns --unix SOCKET -- cmd` the session is the mount itself: m=$NINE_MOUNT
cat "$m/index"
-cat "$m/pane/$PARDES_PANE/body"
```
-From there the editor is files: `cat`, `echo >`, `ls` and shell scripts are
-the whole interface, and nothing below needs more than they do.
-
-`pardes FILE` run in a pane opens FILE in that session and returns at once;
-`pardes --wait FILE` returns when that pane is deleted (1 if the session goes
-away), which is what `EDITOR='pardes --wait'` needs (`GIT_EDITOR` follows it).
+`$NINE_MOUNT` is set by `9ns --mntgen` (usually `/mnt/9p`). `$PARDES_9P` is
+the session socket of the pane you run in, `$PARDES_PANE` that pane's
+serial. Under `9ns --unix SOCK -- cmd` the session is the mount itself:
+`m=$NINE_MOUNT`. In a `Tty9p` shell: `m=$PARDES_MOUNT`. A dead session's
+entry answers `Input/output error`: name the session, never glob. A running
+session serves the binary that started it.
-Entries for dead sessions stay listed and answer `Input/output error` on any
-access, so name the session you mean rather than globbing or taking the newest.
-`cat "$m/index"` is the cheapest liveness check, and `cat "$m/status"` reports
-the `pid`, `version` and `panes` of a session new enough to serve it.
+## Look around
-`$NINE_MOUNT/pardes` is the registry of posted sessions, mounted once for the
-machine. It is not the per-pane kernel mount the `Tty9p` builtin makes, which
-gives one pane's shell `$PARDES_MOUNT`; see [docs/v9fs.md](../../../docs/v9fs.md)
-for that. Either mountpoint serves the same tree. A `9ns --unix SOCKET -- cmd`
-mount exists only inside `cmd`'s private namespace, and there `$NINE_MOUNT` is
-the session's own root (`$NINE_MOUNT/index`), not a registry; to share one,
-use the registry (`9ns --mntgen`).
+```sh
+cat "$m/index" # serial kind dirty name column, a pane a line
+cat "$m/layout" # columns, then `active <serial>`
+cat "$m/focus" # the pane with the keyboard
+cat "$m/README" # the one-screen guide
+cat "$m/commands" # every builtin and where it goes
+```
-A running session serves whatever binary started it. If a listing does not
-match this document, that session predates the change; restart it.
+Parse an index row with `rsplit(maxsplit=1)` first: names may hold blanks.
+Re-read the index after anything that opens or closes panes.
-## The tree, and what to do with it
+## Open a file, go to a place
+```sh
+echo "$PWD/main.zig:120" > "$m/look"; cat "$m/look" # serial it landed in
+echo "main.zig:0/fn main/" > "$m/look" # first match; without 0, the next after dot
```
-$m/README the served guide, worth reading first
-$m/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name, column serial
-$m/status pid, version, panes
-$m/look write a line = a right click on it at the active pane (file:/re/, file:#n, :/re/
- select by address, as acme's look, from the file's dot: file:0/re/ for the
- first match; file:N selects the line; a miss changes nothing, logs err, and
- look reads back empty)
-$m/exec write a line = a middle click: an editor command word, or a shell line
-$m/pane/<n>/pty/run write one line, read `exit N` + its output, or `busy` / `error ...`, on the same open:
- exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3
- (a fresh terminal: waits for its first prompt; if none comes, interrupt the read)
-$m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg <serial|-> <text>,
- run <serial> <line>, exit <serial> <N|?>, send <from> <to> <repl-id>,
- ask <serial> <what> <choices>, answer <serial> <choice|->, changed <serial>
- [reloaded|deleted] (under unsaved edits; a clean reload; gone from disk),
- unsaved <serial> <name> (each pane a refusal is about), newcol|delcol <serial>, restored|restoredcol <old> <new>,
- dump|restore <path>, err <serial|-> <file>: <why>
- (exec 3<>$m/log; echo follow >&3; cat <&3 replays what is there, then waits for new
- ones; `echo follow new` waits for new ones only; tail -f does not wait;
- while read -r line <&3; do ...; done loses nothing within a session (through
- FUSE, `read -t` never times out: use `timeout N` around the loop): a Restore
- hangs every connection up, so dial again, and restart a 9ns mount)
-$m/screen the rendered screen as JSON, frozen per open
-$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;
- `size <cols> <rows>` sizes a --detach session no frontend is attached to (160x50 before)
- (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,
- logging `unsaved <serial> <name>` for each unsaved pane, then failing the write with
- `<name>: Modified (Exit again to discard)` for one or `4 unsaved panes: Modified (Exit
- again to discard)` for more (Restore's says `Restore again`); on screen the list stays
- in the one +Unsaved pane under a short notice (`3 unsaved panes — Exit again to discard`); a second refusal names only the panes edited since the last,
- as acme's does, and the same word again with nothing edited since DISCARDS them all -- not a retry, unlike lock's `file in use`;
- Kill stops a command pane's whole line, `&` jobs included; of a line typed into a
- shell only the foreground job, and the shell decides the rest (of `sleep 30; echo done`
- bash and fish both run the echo); Joincol folds the keyboard's column into the one
- on its right, its panes going below that column's own, in order, and needs such a column);
- 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`, `pane` or `both` (which ctl takes
- it), a setting's values comma-joined, then ` -- ` and one sentence of what it does
-$m/recent files opened lately, closed ones too, most recent first: `open|closed <path>`;
- lost a file you closed? read this, or run Recent and look at its row to reopen it
-$m/layout one line per column (16 columns at most: Newcol past that fails, no space for a column): serial index x width current|notcurrent empty|full pane-serials...; active <serial>
-$m/tag the workspace tag (> replaces, >> appends, one line); $m/col/<serial>/tag a column's (serials stay, as panes' do)
-$m/tagexec a word as a click in the workspace tag (pane/<n>/tagexec: in that pane's tag); every exec
- file reads back the serials its last write touched
-$m/col/<serial>/ctl Delcol, Joincol, New, Tty on that column; col/<serial>/exec a word as a click in its tag;
- rmdir col/<serial> closes an empty column
-$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);
- a session holds 64 panes (16 on the board): past that, every route that opens one
- fails with `no space for a pane: 64 max` (ENOSPC), and so does one whose column
- has no room (each pane keeps its tag and 2 rows: `no space for a pane in that column`); 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
-```
-The first field of an index row is a stable pane serial, not a slot or row
-number. A row is `serial kind dirty name column`, the last word the pane's
-column serial, so `head, col = row.rsplit(maxsplit=1)` then
-`serial, kind, dirty, name = head.split(maxsplit=3)` keeps a name with spaces
-intact. Re-read the index after anything that might open,
-reuse or close a pane.
+A miss opens nothing, reads back empty and logs an `err <serial> look: ...`
+line; the write itself succeeds. `L:C` columns are bytes.
+
+## Make, fill, name and save a pane
```sh
-cat "$m/index" # which panes exist
-n=$(cat "$m/pane/new") # make one, take its serial
-printf 'text\n' > "$m/pane/$n/body" # replace its text (> truncates)
-printf 'more\n' >> "$m/pane/$n/body" # append
-cat "$m/pane/$n/tag" # what its tagline offers
-echo notes.txt > "$m/pane/$n/name" # rename the buffer
-echo Save > "$m/pane/$n/exec" # save it (or to its ctl)
-echo Tty > "$m/pane/$n/ctl" # a terminal in its directory
-echo "/etc/hosts:3" > "$m/look"; cat "$m/look" # open a file, see where it landed
-rmdir "$m/pane/$n" # close it, dirty or not
+n=$(cat "$m/pane/new") # each open makes one: read it once
+printf 'hello\n' > "$m/pane/$n/body" # > replaces the text
+printf 'more\n' >> "$m/pane/$n/body" # >> appends
+echo "$PWD/notes.txt" > "$m/pane/$n/name"
+echo Save > "$m/pane/$n/ctl"
+rmdir "$m/pane/$n" # close it, saved or not
```
-A session may open its own mount from inside itself: a Look at `$m/anything`
-in the editor that serves `$m` is answered on the connection's task while the
-editor's own syscall waits. `/n/self/...` names the same tree without leaving
-the process.
+## Edit text
-**Opening** `$m/pane/new` is what makes a pane, and reading the open file
-answers its serial — `/net/tcp/clone`'s mechanism. Each open makes another one,
-two reads of the same open file answer the same serial, and closing it leaves
-the pane. A pane is named by the serial the editor gives it, never by a name
-you choose.
-
-A *stat* makes nothing, which is the whole reason the allocation sits on open:
-`new` is listed in `$m/pane`, so `ls` shows it, and `ls -l`, `find` and
-anything else that stats every name a listing handed it stay inert. acme
-allocates on the walk instead and lets it land inside the new window, so
-`/dev/new/body` works in one step — it can afford that because a Plan 9
-directory read carries every entry's stat and nothing walks. Under a kernel or
-FUSE mount that would be a pane per `ls -l`. Nothing else in the tree can be
-created or removed, and no read creates anything.
+```sh
+p=$m/pane/$n
+echo 'Edit ,x/foo/c/bar/' > $p/ctl # sam edit, one undo step
+echo '/old/' > $p/addr; printf 'new' > $p/data # replace the next match
+echo 0 > $p/addr # back to the top
+echo '3' > $p/addr; : > $p/data # delete line 3
+cp $p/addr $p/dot; cat $p/sel # select it, read the selection
+```
-`look` and `exec` are the editor's two clicks, one per line of a write, at the
-active pane from the root and at that pane from `$m/pane/<n>/look` and
-`$m/pane/<n>/exec`. Reading any of them answers the serials the last command
-made, or the pane it focused or acted on: an open's own last write's, or, on
-an open that never wrote, the session's last. With other clients about,
-write and read one open (`exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`).
-A read is a stream: once read, a second read on the same fd gives EOF (on
-an open that wrote, until its next write); open again, or seek to 0, to read
-it again. Each command line runs once whole, however a mount cuts a big write; a
-write's last line with no newline runs with it (and fails it), unless the
-write filled its 9P message and may go on; an Edit block still open when the
-file closes fails there (an `err`: ``unmatched `{'``, or an a/c/i text with no `.`
-line), changing nothing. One rule: a builtin that fails, whether through a
-ctl (a pane's pty/ctl `exec` too), look, exec, tagexec or a column's exec,
-fails the write (EINVAL for a malformed line, ENOENT for what is not there --
-a directory gone, a Find or Grep with no hit -- else EIO or an errno that
-fits) and logs one `err` with the reason, no `msg`. A look that finds nothing is no failure: it answers
-nothing and logs one `err`. A command line run in a command pane is judged
-by its `exit` record. A word no builtin
-knows (a typo included) is a command line: written at a terminal at its
-prompt it is typed into that shell; from anywhere else it runs as a command
-pane, a terminal whose child is the root ctl's `Shell` ($SHELL, else /bin/sh, unless set)
-running `-c` the line in the pane's directory, which ends showing `exit N` (a typo: `exit 127`) and logs `run
-<serial> <line>` and `exit <serial> <N|?>` -- `exec` reads back its serial,
-so follow `log` for the exit. The directory's next command reuses a finished
-command pane, below what it showed. A terminal bound as a REPL (`Repl
-python` on its ctl) takes the middle clicks made on a `.py` body instead,
-but never a 9P write: a script sends code by writing the REPL pane's
-`pty/data`, and runs a command from such a file with `Exec <text>` or the
-tag. A bound terminal's `ctl` line ends with its id (`python-a`); an event
-record written back from such a file's body goes to its REPL, as the click
-would. `Repl -` unbinds; a bare `Repl` says the binding. With several
-REPLs bound for a language an exec asks which, logged `ask <serial> repl a b`
-(Del's side from the keyboard is `ask <serial> del k j`): answer with
-`echo 'answer a' > $m/pane/<serial>/ctl`, or `answer -` to send nothing (the
-log says `answer <serial> a|-`). Kill does not stop what a REPL runs (it was
-typed, not started by pardes): `echo 'sig INT' > $m/pane/<repl>/pty/ctl`
-interrupts it. A range of a `.py` pane goes to its REPL by writing the event
-record `MX<q0> <q1>` to that pane's `event`. A chorded exec's record (flag 8)
-is followed by two, its argument and where it came from; write all three back
-as read (one write or three) and it runs once with its argument. `Tty`'s argument is a shell
-(`Tty fish`), and `Tty` on a pane's ctl opens a new terminal pane. After a
-Restore, panes have new serials (`restored <old> <new>` in the log), a
-command pane shows how it ended, `exit N` (`exit ?` if it was still running when
-dumped), and does not run again, and REPLs are unbound. Multi-line code
-written to `pty/data` should be a bracketed paste, `\e[200~<code>\e[201~`,
-then, in a separate write once the REPL has echoed the paste (Python 3.13+
-takes a `\r` read with the paste as part of it, even for one line), `\r`.
-A paste of one line needs that one `\r`; a paste of more than one line
-needs a second `\r` unless the pasted code ends in a newline (one Enter
-leaves a multi-line input at `...`). A middle click, or an event
-write-back, sends what it needs by itself. Sent line by line, a blank line ends a Python block, and Python
-3.14's REPL auto-indents each line it is typed. Every refused 9P write adds an `err <serial|->
-<file>: <why>` record to `$m/log`; through a mount the write itself only says
-`Invalid argument`. Only writes log one: a refused open or truncation
-(an OTRUNC open, as `data`'s after a failed `addr`), create or remove is
-its error alone, as are a write to `pane/new` (`permission denied`) and a
-write on a fid opened read-only (`bad use of fid`). Errors are words, never
-a C errno string. A look miss quotes what was written: `no match for "zzq:#3"`.
+`addr` searches on from the last address (it is the pane's, shared by all
+clients); after a `data` write it sits just past the text, so write `addr`
+before each replacement. A failed `addr` leaves none and `data` refuses:
+check the write's status. An Edit block of several lines goes in one open:
+`printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $p/ctl`.
-## Edit through addresses, dot and the flag files
+## Run a command
-For a file or scratch pane, with `pane=$m/pane/<serial>`:
+```sh
+echo 'make test' > "$m/exec"; c=$(cat "$m/exec") # a command pane; its serial
+grep "^exit $c " "$m/log" | tail -1 # `exit <serial> <N>` once done
+```
-Rename everywhere, or any sam edit, is one write: `echo 'Edit ,x/foo/c/bar/'
-> $pane/ctl`. `Edit` takes sam's command language (acme's): addresses, `x y g
-v c a i d s p = m t u` and `{ }` (commands in braces one to a line, so from
-exec or a tag, not a one-line ctl write). Its changes are one undo step, and
-one that fails changes nothing and fails the write with acme's words (`Edit:
-no substitution`), logged as `err`; an `x` that finds nothing is no failure
-(sam's), so `,x/zzz/c/bar/` with no `zzz` succeeds silently. A block goes to the pane's `ctl`, the
-root `ctl` (the active pane) or `exec` on one open, in one write or several
-(bash's `printf` writes line by line): an `Edit` line takes the lines after it
-until its `{` closes or its `a`/`c`/`i` text ends with `.`, and runs then. `sel`
-reads the selected text, and a write to it replaces the selection:
+At a terminal's prompt, run and get the status and output in one go:
```sh
-cat > $pane/ctl <<'END'
-Edit ,x/area_of/{
-i/[/
-a/]/
-}
-END
-env printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $pane/ctl
+t=$(awk '$2=="term"{print $1; exit}' "$m/index")
+exec 3<>"$m/pane/$t/pty/run"; echo 'ls' >&3; cat <&3; exec 3<&- # `exit N`, then output
+printf 'q' > "$m/pane/$t/pty/data" # raw keystrokes (\r Enter, \x03 Ctrl-C)
+echo 'sig INT' > "$m/pane/$t/pty/ctl" # interrupt
```
- `p` and `=` print to the directory's
-`+Errors`. Not there: `b B D e r w f X Y`, `< | >`, and `\1`-`\9` in `s`.
-
-> **sam gotchas** (as sam does them, pardes too)
-> - `$-1` is the last line when the text ends in a newline (`a\nb\n`: `b\n`),
-> but the one before it when it does not (`a\nb`: `$` is on `b`, so `a\n`).
-> - With a final newline, the line after the last is the empty place at the
-> end, not an error: `N+1` at the last line is `#<len>,#<len>`. Without one
-> (`a\nb`), the last line runs to the end and `N+1` is out of range.
-> - `^` and `$` match at the text's end too: `/^/` from the end finds the
-> empty place after a final newline.
-> - `2,1` is no error: it is `#<start of 2>,#<end of 1>`, an empty range at
-> line 2's start (only a range whose end is before its start is refused).
-> - `y` yields the stretch before the first match too, empty if the text
-> starts with one: `,y/a/` on `abc` gives `` and `bc`.
-> - `c/&/` puts a literal `&`; only `s` expands `&` to the match.
-> - `line:col` columns count bytes from 1: `12:0` is refused
-> (`a column counts from 1`); a tool's character column is the same only
-> on an ASCII line.
-| Operation | Shell | Python client |
-|---|---|---|
-| Read text | `cat $pane/body` | `client.read(pane + '/body')` |
-| Append text | `echo text >> $pane/body` | `client.write(pane + '/body', b'text\n')` |
-| Replace all text | `echo text > $pane/body` | `client.write(pane + '/body', b'text\n', truncate=True)` |
-| Address a byte range | `echo '#0,#2' > $pane/addr` | `client.write(pane + '/addr', b'#0,#2')` |
-| Replace that range | `printf 'pub fn' > $pane/data` (`>>` too) | `client.write(pane + '/data', b'pub fn')` |
-| Delete that range | `: > $pane/data` | truncate `data` (open with OTRUNC) |
+`busy: <program> is running` means the prompt is not free: a program holds
+the terminal, so talk to it through `pty/data`.
-A write leaves `addr` just past what it wrote, so a second `echo x > data`
-inserts after the first: write `addr` again before each replacement. The
-writes of one open of `data` are one undo step (a multi-line `printf` too);
-to make a loop's writes one, `echo 1 > mark`
-(an undo point now), `echo 0 > mark`, the writes, then `echo 1 > mark`.
-A `+New` scratch counts as unsaved (and holds up Exit, Restore, Del) only
-once it holds 100 bytes or more. A read
-of `data` or `xdata` moves `addr` past what it read, as in acme.
-Truncating `data` is pardes's own (acme ignores OTRUNC and always inserts).
-plan9port's `9p write` always opens with OTRUNC, so `… | 9p write pane/body`
-replaces the whole body: append with `>>` through a mount instead.
-| Read the selection | `cat $pane/dot` (offsets), `cat $pane/sel` (text) | the same two reads |
-| Select the addressed range | `cp $pane/addr $pane/dot` | `client.write(pane + '/dot', client.read(pane + '/addr'))` |
-| Reload from disk | `echo get > $pane/ctl` (refused once while there are unsaved edits; again discards) | `client.write(pane + '/ctl', b'get\n')` |
-| Close it | `rmdir $pane` | `client.remove(pane)` |
+## Follow what happens
-`addr`, `dot` and `limit` each read the pair of offsets they also accept, which
-is why copying one onto another is all that acme's `addr=dot`, `dot=addr` and
-`limit=addr` ever were; a write may also be an address expression (`#0,#5`,
-`/pattern/`, `2+1`, or pardes's `12:5`: line 12, byte column 5, composing as
-`12:5,14:1`; a Recent, +Search or Jumplist row's `12:5-14:2` is taken too,
-through 14:2 inclusive), whose regexps are mvzr's searched as sam searches:
-`^`/`$` match at any line's start and end, `.` and `[^...]` never match a
-newline, the leftmost match wins (the first alternative there, not the
-longest). In a pattern with `\n`, `^` works only first (`^def .*\n` finds
-every def line) and `$` only just before a `\n`; anywhere else the pattern is
-refused, not silently unmatched. `^def|^ ` finds lines starting either way
-(every branch anchored; a mix like `^def|x` is refused). `[éa-z]` and
-`[à-ÿ]` match those runes (a range up to 256 runes); `[^é]` and a wider
-range are refused. An expression is evaluated from the current address (the last one
-written, or just past the last `data` write): `.` is that address, not the
-selection, `/re/` searches on from its end and wraps unless `limit` is set,
-`?re?` or `-/re/` searches back, `#100,#50` fails `addresses out of order`,
-and one search that runs past its step budget (about 300 ms; each search of
-an Edit `x` has its own) fails with `regular expression search took too
-much time, gave up`. A failed address says
-why (`no match for regexp`, `address out of range`) and leaves no address:
-`addr` reads empty and `data` refuses until the next good one, so a missed target is never
-written at the old one. Moving `dot` scrolls the pane to it. `limit` bounds
-only the end of a forward search, as in acme, and reads empty until set;
-truncate it to lift it. In `12:5` the column counts bytes from 1 and clamps
-past the end of the line; a line past the end is `address out of range`.
-A refused write repeated the same way is one `err` line counted, `(x4)`.
+```sh
+exec 3<>"$m/log"; echo 'follow new' >&3
+timeout 30 cat <&3 # one record a line, as they come
+exec 3<&-
+```
-`dirty`, `mark` and `scroll` read `0` or `1` and take `0` or `1`: whether the
-buffer differs from its file, whether a write pushes an undo point, and whether
-a write scrolls. `tag` reads the path, then the text you may edit: `> tag`
-replaces that text (default words too), `>> tag` appends to it.
+`follow` (without `new`) replays the ring first. `tail -f` sees nothing new.
+Through FUSE `read -t` never times out: use `timeout`. After a `Restore`
+every connection is cut: dial again and restart the mount.
-Address state belongs to the pane, not to a client: it keeps the last range
-written until someone writes another, so writing an address and reading it back
-evaluates it, and two clients addressing the same pane will interfere. Neither
-an open nor a `>` resets it (acme resets it on the first open): each `echo /re/
-> addr` searches on from the last address, so a find-and-replace loop advances.
-Write `0` to start from the top. The search wraps, so stop a find-all loop when
-the address comes back to where it began, or set `limit`.
-`$pane/ctl` reads acme's window status line — serial, tag length, body length,
-isdir (0), the dirty flag, the width in cells, the font, the tab width, the
-undo flag and the redo flag, as acme's fields — then `current` or
-`notcurrent` — and takes `get` (reload from disk),
-`lock`/`unlock`, and any builtin that acts on a pane (`Del`, `Save f`,
-`Collapse`, `Undo`/`Redo`: 256 steps; with none left they say so, and the
-write succeeds). A `lock` another open holds fails at once with `file in use`
-(EBUSY): retry it. Session builtins and settings go to the root `ctl`, which reads
-back every setting in the syntax it takes. A ctl write is checked whole
-first and refused as `unknown control message "X"` (EINVAL) and the like,
-a required argument missing included (`wrong #args ... "Mount"`); then a line
-whose builtin reports an error fails the write with that error and the line
-(EIO, or an errno that fits: ENOENT for a missing pane, file or dump), after the lines before it took effect. A `Save` on a scratch fails
-rather than prompt. The lock binds only clients that take it, and is held by the
-open that wrote it, so a shell holds an fd across the edit:
-`exec 3>$pane/ctl; echo lock >&3; ...; exec 3>&-`.
+## Traps
-Terminal panes have no file: writing their `body` sends child input, and
-truncation does not erase terminal history.
+- A refused write says only `Invalid argument` or `Input/output error`; the
+ reason is the `err` line in `$m/log` (`tail -1 "$m/log"`).
+- Only writes log: a refused open, truncation (`> data` after a failed
+ `addr`) or `rmdir` has its errno alone.
+- Settings and session words go to `$m/ctl`; pane words (`Undo`, `Save`,
+ `Del`) to `$m/pane/<n>/ctl`. The wrong one is refused naming the right one.
+- `Exit`, `Restore`, `Del`, `get` refuse once over unsaved text; the same word
+ again discards.
+- A word no builtin knows is a shell command (`exit 127` if a typo).
+- `lock` needs a held fd: `exec 3>$p/ctl; echo lock >&3; ...; exec 3>&-`.
+- plan9port `9p write` truncates: it replaces a whole `body`.
-## The Python client, for what a shell cannot express
+## No mount: the Python client
-Use the existing [Python client](../../../test/ninep.py) when there is no
-mount, or when the work needs a fid held open across several operations —
-`log`, `event` and `pty/data` are consuming queues whose reads park, and shell
-redirection cannot hold one open.
+Use [test/ninep.py](../../../test/ninep.py) when nothing is mounted, or when
+a fid must stay open (`event`, `pty/data`, a followed `log`). Its paths are
+the served root (`/index`, `/pane/2/body`).
```sh
PYTHONPATH=test python3 -B - "$PARDES_9P" <<'PY'
import sys
from ninep import Client
-
-with Client(sys.argv[1]) as client:
- print(client.read('/index').decode(), end='')
- print(client.read('/listeners').decode(), end='')
+with Client(sys.argv[1]) as c:
+ print(c.read('/index').decode(), end='')
+ n = int(c.read('/pane/new'))
+ c.write(f'/pane/{n}/body', b'hello\n', truncate=True)
+ fid = c.open(f'/pane/{n}/event', 0) # hold event: clicks come here
+ c.write(f'/pane/{n}/exec', b'Msg hi\n')
+ print(c.read_fid(fid, 0, 4096)) # b'FX0 0 1 6 Msg hi\n'
+ c.close(fid)
+ c.remove(f'/pane/{n}')
PY
```
-`Client` takes a raw Unix socket path, or `(numeric_ip, port)` for TCP; it does
-not parse Pardes dial strings or implement QUIC. It negotiates 9P2000, uses a
-five-second socket timeout, and closes on leaving `with`. Its paths are the
-served root: `/index`, `/pane/2/body`, `/os/...`. `/n/self`, `/n/os`, `/n/peer`
-and `/virtual` are editor Look paths, not server paths — Look
-`/virtual/src/pardes.zig` corresponds to reading `/src/pardes.zig`.
-
-For live terminal output or plugin events, use `open` / `read_fid` / `close`,
-not the read-until-EOF helper. `pty/data` captures output while held open; it
-is not a history replay. Both files are shared, consuming queues, not
-per-client broadcasts, so a slow reader loses older data. Holding `event` open
-intercepts that pane's Look and Exec clicks -- and lines written to that pane's
-own `look`/`exec`, or to the root's while it has the keyboard, as `F` records
-at `0 0` with the text, and clicks in a terminal's body, also at `0 0` -- so
-it is not a passive logger: a helper holding `event` that writes its own
-pane's exec gets its command back as a record; run it through `ctl` or write
-the record back. To have a record done, write it back: the short form
-`<origin><action><q0> <q1>\n` acts on that range's text, and the whole record
-as read acts on its text when the range is empty (the only way for a record
-at `0 0`). Chord reports need explicit handling. A record is
-`<origin><action><q0> <q1> <flag> <n> <text>\n` and its text may hold
-newlines: read `n` bytes of text, never up to the next newline (acme counts
-runes; pardes counts bytes, as all its offsets are). Every address lands on a
-rune boundary, never inside one and never widened to a grapheme cluster: `#n`
-or `line:col` inside a rune snaps back to its start, a match covers the runes
-it touches, and a combining mark or a CRLF's `\r` is addressable alone.
-A Restore puts a new editor under every client: the Restore write is
-answered, then every connection is hung up (their fids name the old
-editor's panes); dial again, and the new log has a `new` for each restored pane, then
-`restore <path>`, then `restored <old> <new>` for each pane and
-`restoredcol <old> <new>` for each column -- `restore <path>` the authority, since a slow client may see the cut
-before the answer. A 9ns older than cloud9 2a7137c could fail the Restore
-write with ECONNRESET although the Restore went ahead; trust the log. `Dump` writes `pardes-<date>-<time>.zon`, the time in UTC, under
-`DumpDir` (`$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`) and logs
-`dump <path>`. A relative `Restore <path>` is looked for in `DumpDir` first,
-then in the directory pardes started in; a bare `Restore` takes the last dump
-this session wrote (none yet: refused, name one).
-Read the event implementation before building an interceptor. Close handles in
-`finally`, and disconnect after a socket timeout. The service shares sixteen
-connection slots (the next client's version gets `too many connections`)
-and 32 screen/terminal-history snapshot handles.
-
-A file's qid version is the pane's revision for `body`, `data` and `xdata`, so
-`stat` sees an edit land without reading the text; it stays zero elsewhere.
-Stat sizes are real: for `event` and `pty/data` the length of the record a read
-would answer (zero when nothing is waiting), for `log` the whole ring, the text
-an open would freeze now.
-
-## Terminal input and screen observations
+`Client` takes a Unix socket path or `(ip, port)`; `client.screen()` returns
+the parsed `/screen`. For `event` records and writing them back, see
+[fs.md#event](../../../docs/fs.md#event).
-Only terminal panes have `pty/`. Write keystroke bytes to `pty/data`, not
-`body`: `client.write(pane + '/pty/data', b'printf hello\r')` submits a shell
-command. For an interactive application, send its actual input bytes; `b'\x03'`
-is Ctrl-C, and Ctrl-U is `b'\x15'` where that application supports it. These go
-to the child terminal, not to Pardes key bindings. `pty/ctl` takes `winsize C R`,
-`sig INT` and `exec` (restart the shell; a directory that is gone fails ENOENT), one per line. `pty/status` reads one line: the pty's cols,
-rows and busy (1 while a command runs or text is typed at the prompt).
+## An isolated session
-`client.screen()` returns `cols`, `rows`, `cursor`, `styles` and row-major
-`cells` of `[grapheme, style_index]`. Reconstruct rows using `cols`; resolve
-each cell's style through `styles` when checking highlighting, and compare
-colors and attributes rather than style-table indices or flattened text.
-
-Each screen open freezes one frame, and a terminal `body` freezes its history
-on its first read. `client.read` and `client.screen` reopen each time, so
-repeat the call for a fresh observation rather than polling a stale handle.
-Poll a specific condition with a deadline and a short delay, not a fixed long
-sleep. For large histories, measure whole-body reads separately from screen
-polling; write-to-observation timing includes RPC, rendering and polling.
-
-## Exercise an isolated session
-
-Reuse [test/fs.py](../../../test/fs.py), which starts a private session with
-temporary configuration and cleans up its editor process. Pass an existing
-native binary, not a benchmark executable:
+Never experiment on a session someone is using. Build a binary with
+`zig build install -Dplatform=tty` (a bare `zig build`, or `--prefix
+zig-out`, installs over `~/.local/bin`). `test/fs.py` starts a private
+session and cleans it up:
```sh
-PYTHONPATH=test python3 -B - /absolute/path/to/pardes <<'PY'
+PYTHONPATH=test python3 -B - "$(realpath zig-out/bin/pardes)" <<'PY'
+import sys, tempfile
from pathlib import Path
-import sys
-import tempfile
from fs import session, new_pane, execute
-
-binary = str(Path(sys.argv[1]).resolve())
-with tempfile.TemporaryDirectory(prefix='pardes-9p-skill-') as directory:
- with session(binary, Path(directory), 'skill') as (client, address):
- serial = new_pane(client, b'fn main() void {}\n')
- pane = f'/pane/{serial}'
- client.write(pane + '/name', b'probe.zig\n')
- client.write(pane + '/addr', b'#0,#2')
- client.write(pane + '/data', b'pub fn')
- assert client.read(pane + '/body') == b'pub fn main() void {}\n'
- execute(client, serial, 'Msg 9p-ready')
- frame = client.screen()
- assert '9p-ready' in ''.join(cell[0] for cell in frame['cells'])
- print('9P edit, builtin and screen checks passed')
+with tempfile.TemporaryDirectory(prefix='pardes-9p-') as d:
+ with session(sys.argv[1], Path(d), 'probe') as (client, address):
+ n = new_pane(client, b'fn main() void {}\n')
+ execute(client, n, 'Msg ready')
+ assert 'ready' in ''.join(c[0] for c in client.screen()['cells'])
PY
```
-`new_pane` opens `/pane/new` and reads the serial it answers; `execute` writes
-one line to a pane's `exec`. Both are in
-`test/fs.py`. For terminal tests, use
-`session(..., tty=True)` and read
-[test/agent_session.py](../../../test/agent_session.py) for bounded interactive
-driving; its readiness text and history threshold are application-specific, and
-session cleanup alone does not guarantee arbitrary grandchildren have exited.
-Use [test/snapshot.zig](../../../test/snapshot.zig) when editor key/mouse input
-or an independent terminal-rendering comparison matters; 9P screen inspection
-alone does not test physical input routing or the host renderer.
-
-Read [docs/fs.md](../../../docs/fs.md) for runtime mounts, TCP/QUIC listeners,
-Plan9port and Linux v9fs compatibility. Unix is always available; network
-listeners are opt-in and grant full session and OS-file access, so use isolated
-loopback listeners for tests. For event details or anything this page leaves
-open, read the implementation and its tests in
-[src/ninep/](../../../src/ninep/).
+By hand: `pardes --detach=NAME &` with its own `HOME` and `XDG_*`
+directories, then `9ns --mntgen` (the session is `$NINE_MOUNT/pardes/NAME`)
+or `9ns --unix $XDG_RUNTIME_DIR/pardes-9p-NAME.sock -- sh`. Strip every
+`PARDES_*` variable first, or a file argument goes to the session you are
+inside. `session(..., tty=True)` and
+[test/agent_session.py](../../../test/agent_session.py) drive terminals.
diff --git a/README.md b/README.md
index 14803aef..1fe1167d 100644
--- a/README.md
+++ b/README.md
@@ -10,41 +10,16 @@ One core, five frontends. The core owns editing, layout, rendering, and the
filesystem namespace. Frontends translate native input into `pardes.Event`,
present `pardes.Surface`, and perform host effects such as spawning processes.
-Fifteen [native Pardes themes](docs/themes.md) coordinate the editor, search,
-diagnostics and embedded terminal: `orchard` (the near-black default), `dusk`,
-`ink`, `paper`, `daybreak`, and the Acme-inspired `atelier`. `ink` and
-`daybreak` provide high contrast dark and light choices. Six classic-inspired
-adaptations add `forge`, `lagoon`, `solarium`, `spectrum`, `harvest`, and `clay`.
-`forge_black` and `orchard_black` offer pure-black variations; `forge_soft`
-offers a deliberately softer contrast. Execute `Themes` to choose native
-themes first, followed by the existing legacy/imported collection. `FocusTint` toggles
-the active pane and column tag tints (on by default), and `SyntaxBold` toggles bold
-syntax keywords (off by default), in both GUI and TTY.
+Every session serves its panes, columns and tags as a 9P control filesystem,
+as acme does ([docs/fs.md](docs/fs.md)). `pardes FILE` run in a pane opens
+FILE in that session; `EDITOR='pardes --wait'` makes it your editor
+([forwarding](docs/fs.md#connecting)). `Tty9p` opens a terminal with the
+tree mounted ([docs/v9fs.md](docs/v9fs.md)).
-SDL also supports `Font <name>:<size>` and optional pixel companions in the
-unused workspace tag: `Pet cat`, `Pet frog`, or `Pet off`.
-
-`Mini path` opens a braille minimap with syntax colors: two text columns by four
-lines per cell. It reads through the normal filesystem namespace; run it again
-to refresh. Mini snapshots survive Dump/Restore without rereading the source.
-
-On Linux, `Tty9p` (`SPC n 9`) opens a terminal with the session's 9P tree
-mounted through kernel v9fs. It asks sudo in that pane, then starts your shell
-as your normal user. Access the tree through `$PARDES_MOUNT`.
-See [mounted terminals](docs/v9fs.md). Beyond acme's per-window files the tree
-serves the layout: `/layout` lists the columns, their places and panes, and
-`/tag` and `/col/<serial>/tag` are the workspace and column tags, editable like a
-window's ([the tree](docs/fs.md#the-served-tree)).
-
-`pardes FILE` in a pane opens FILE in that session and returns at once. As
-`$EDITOR`, use `EDITOR='pardes --wait'` (`GIT_EDITOR` follows it): `--wait`
-returns when that pane is deleted, so fish's Ctrl-O, `git commit` and
-`crontab -e` read the file after you edit it
-([forwarding](docs/fs.md#filesystem)).
-
-plan9port's `9p write` always opens with OTRUNC, so `echo x | 9p write
-pardes/<id>/body` replaces the whole body where acme would append. To append,
-write through a mount with `>>` (`echo x >> $PARDES_MOUNT/<id>/body`).
+Fifteen [native themes](docs/themes.md) lead a ring of ports and imports;
+`Themes` lists them. `Recent` (`SPC f r`) lists files opened lately, closed
+ones too, and reopens one at its last place. `Mini path` opens a braille
+minimap. Settings and the startup file are in [docs/config.md](docs/config.md).
## Requirements
@@ -53,7 +28,7 @@ fetched and pinned by the manifest; no system package is required for the
terminal build. The SDL shell builds SDL3 and FreeType from source. Native PDF
support builds MuPDF and is on by default (`-Dmupdf=false` to drop it).
Optional 9P-over-QUIC support (`-Dquic=true`) uses system OpenSSL 3.6+ and
-pkg-config. The default Unix socket and optional TCP transport do not need it.
+pkg-config.
## Build
@@ -61,76 +36,42 @@ pkg-config. The default Unix socket and optional TCP transport do not need it.
zig build
```
-That is the whole of it, and it is an *install*: it builds both native shells and
-puts them in `~/.local/bin`.
-
-```
-~/.local/bin/pardes the terminal shell (libvaxis)
-~/.local/bin/pardes-gui the SDL3 window
-```
-
-Override with `--prefix <dir>`. Test, benchmark and run steps build what they
-need without installing development binaries.
+A bare `zig build` builds both native shells and **installs** them into
+`~/.local/bin` (`pardes`, the terminal shell; `pardes-gui`, the SDL window;
+`pardes-v9fs`, the Tty9p helper). `--prefix <dir>` installs elsewhere, except
+`--prefix zig-out`, which counts as no prefix. With `-Dplatform=<shell>` the
+default prefix is `zig-out`. Test, benchmark and run steps build what they
+need without installing.
-```
-pardes --version e.g. pardes 0.0.1 (e61bbb2e86bd)
-pardes --help every flag
-```
-
-The version comes from `build.zig.zon`'s `.version`; the commit is read from
-`git` at configure time and is simply absent when there is no repository to ask.
-
-## The five platforms
+`pardes --version` prints `pardes <version>`, plus the commit for release
+builds and the `~/.local` install (`-Dstamp-commit=true` forces it).
+`pardes --help` lists every flag.
| build | what it is |
|---|---|
| `zig build` | the terminal shell and the SDL window, together |
| `zig build -Dplatform=tty` | the terminal shell alone |
| `zig build -Dplatform=gui` | the SDL3 window alone |
-| `zig build web -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` | a freestanding wasm core plus vanilla JavaScript, rendered as HTML/CSS |
-| `zig build -Dplatform=macos` | an AppKit and CoreText app over a static `libpardes.a` |
+| `zig build web -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` | a freestanding wasm core plus vanilla JavaScript ([docs/web.md](docs/web.md)) |
+| `zig build -Dplatform=macos` | an AppKit and CoreText app over a static `libpardes.a` ([docs/macos.md](docs/macos.md)) |
| `zig build -Dplatform=esp32p4` | a freestanding riscv32 editor object for ESP32-P4, using a 384 KiB heap |
Firmware images are built in the sibling `../05-zig-p4` toolchain, which needs
ESP-IDF register headers. After building the editor object here, `zig build
-Dpardes` there links `src/esp32p4/app.zig`. The separate GPIO 9P image uses
-`zig build -Dapp=../02-pardes-code/src/esp32p4_9p.zig` there; it does not link the
-editor. Its fixed GPIO namespace is in `src/esp32p4_gpio.zig`.
+`zig build -Dapp=../02-pardes-code/src/esp32p4_9p.zig` there; its fixed GPIO
+namespace is in `src/esp32p4_gpio.zig`.
## Detached sessions
-A shell need not be in the same process as the core.
-
```
pardes --detach=work a core with no terminal of its own
pardes --attach=work become a frontend of it
pardes-gui --attach=work ...the SDL window can attach too
```
-Several frontends may be attached at once and all see the same screen. The
-detached core performs every effect that needs a disk or a process table, so its
-pane shells outlive every frontend; a frontend keeps only what needs the human's
-own display. From inside the editor, `Attach` and `Detach` do the same thing as
-words. See `docs/detached.md`.
-
-## Recent files
-
-A file closed by accident is found again. `Recent` (`SPC f r`) lists the
-files opened lately, most recent first, closed ones too, each marked `open`
-or `closed` at the place its dot was when it closed:
-
-```
-/home/me/src/main.zig:120:5 closed
-/home/me/notes.txt open
-```
-
-A look at a row opens that file there. The list keeps 200 files, each once,
-and lasts across sessions in `$XDG_STATE_HOME/pardes/recent` (else
-`~/.local/state/pardes/recent`). The jumplist keeps a closed file's entries
-too: `Back` to one opens the file again at its place, and `+Jumps` marks it
-`(closed)`. Over 9P, `/recent` reads `open <path>` or `closed <path>` a line.
-acme has nothing like it (its dump and Load are the nearest); pardes goes
-beyond acme here.
+The detached core owns panes, shells and files; frontends come and go. See
+[docs/detached.md](docs/detached.md).
## Tests
@@ -169,130 +110,66 @@ zig build web-driver-test browser driver timeouts and cleanup
zig build web-e2e Chrome-driven DOM end-to-end suite
```
-`snap -- --record=/tmp/captures` writes independent snapshots for comparing
-binaries without changing goldens. `snap -- --update` replaces goldens;
-`snap -- --no-retry` makes the first failure decisive. Retried runs retain
-each attempt's report and mismatching grid in a fresh printed directory.
-For custom differential cases, use
-`zig build hxdiff -- --strict cases.jsonl reference.jsonl [waivers.jsonl]`.
-Without a reference, the driver only emits results; exit zero does not mean a
-comparison passed. Custom arguments must include `--strict` to check coverage.
-The benchmark steps — `perf`, `pdf-bench`, `pdf-scroll-bench`, `pdf-sections-bench`,
-`lspbench`, `fs-bench` — all accept `-- --json`.
-
-Snapshot scripts can use `snap9p` to capture core cells and styles through the
-default socket alongside the terminal emulator's independent captures.
-
-`zig build monkey -Dplatform=tty -- <seeds> [steps] [--from=N] [--out=DIR] [--keep]`
-writes one random script per seed (keys, leader bursts, typing, clicks, drags,
-wheel, tiny resizes, prompts and long prompt text over bad-UTF-8 and wide-glyph
-files), runs it through pardes-snap and looks for a panic's `.zig:N:N: 0x`
-frame in the report and in the captured grid. A hit keeps the script, its
-captures and its log in DIR (default `/tmp/pardes-monkey`); a seed always makes
-the same script, so `--from=N` with one seed replays it. A trace longer than
-the grid scrolls away: replay the kept script at a larger `start` size. Each
-crash found gets a fix and a regression script in `test/snapshots/`. The tty's
-tagline is the body's pitch, so what a narrower tagline does is fuzzed by a
-unit test instead (`a monkey over notices…` in `src/draw.zig`, longer with
-`PARDES_FUZZ_SEED`/`PARDES_FUZZ_STEPS`).
-
-`-Dtest-filter=<text>` applies to every unit-test binary. Use
-`-Doptimize=ReleaseFast` for performance measurements. To record output and
-first/warm command runtimes across revisions, run:
-
-```
-jj status
-zig build history -- run 'ancestors(@, 2)' /tmp/pardes-history -- zig build unit-test
-zig build history -- compare /tmp/pardes-history/<before>.json /tmp/pardes-history/<after>.json 1.20
-```
-
-The history runner uses `jj run` serially, with recordings outside its isolated
-checkouts without integrating history changes. Run `jj status` before measuring
-`@`: the runner deliberately does not snapshot pending edits. `history record`
-measures current files directly. Add `run --allow-immutable`
-to measure immutable revisions. Each command runs four times; the first run is reported separately
-from the warm median. Standard output and errors are preserved for every run.
-Commands must exist in the selected revisions. The optional comparison ratio
-fails the command when runtime regresses beyond that limit. Add `--snapshots`
-to require stable, identical stdout and stderr, or `--benchmarks` to compare
-individual `syntax-bench` cases, including allocation counts; the ratio limit
-also applies to each case's cold and median time, using medians across the last
-three processes. First-process cold times are reported separately, not gated.
-All four captures must contain the same cases and input sizes. Old revisions
-need the same harness and uncached test execution for meaningful comparisons.
-The recorded Zig version identifies the recorder's compiler; record the child
-toolchain separately when comparing different compilers.
-Recordings are exclusive: use a fresh output directory to repeat a measurement.
-Existing results and partial runs are never overwritten.
-
-`perf -- --base old.json` requires matching build metadata, harness, viewport,
-repetition count, fixtures, and `--only` selection. Reports identify the compiler,
-resolved target/CPU, driver/core/dependency optimization modes, and feature flags.
-Missing or different metadata is rejected; old reports must be regenerated.
-JSON reports omit unmeasured cells. Gesture checks run
-outside the timed interval, and each process owns and removes its fixture directory.
-Hardware, machine load and runtime library versions must be matched separately.
-
-`zig build perf -Dplatform=tty -Doptimize=ReleaseFast -Dtree-sitter=zig -- --mini --json`
-measures braille generation, full highlighting plus generation, and cached Mini
-redraws separately, checking output hashes and allocation balance.
-
-`zig build perf -Doptimize=ReleaseFast -- --terminal-mib 64 --json` streams
-colored, wrapped agent-like output past the terminal history limit. It checks
-the newest text, colors and input, and measures raw redraws, modal movement,
-edit-overlay redraws and memory. Linux RSS comes from the current process image's
-`VmHWM`; allocator counts exclude the terminal library's private mappings.
-
-`python3 -B test/agent_session.py zig-out/bin/pardes --ready 'ready text' --min-rows 20000 -- command args`
-runs a caller-selected interactive command in a private POSIX shell and checks
-its history and an unsubmitted input probe through 9P. Use an owned copy of any
-saved conversation.
-Reports contain counts, hashes and 9P observation times, not conversation text
-or physical keyboard latency. GUI builds need `--gui-grid`.
-
-`test-build -Dtest-rebuild` forces fresh Zig test compilation while retaining
-cached C dependencies. Use a disposable local cache for repeated cold-build
-experiments. Ordinary `unit-test` always runs its tests, even with cached builds.
-`unit-profile` measures the request-to-result interval, including test-runner
-communication, and reports whole-process time and memory separately.
-Filtered test steps fail if no named test matches, even when import guards pass.
-
-The default differential suites require exact case coverage and reject stale
-waivers. Each waiver pins both the reference and editor result. Live Helix
-steps use `-Dhelix-harness=<path>`, `HX_HARNESS`, or `hx-harness` on PATH.
+- `-Dtest-filter=<text>` applies to every unit-test binary; a filter that
+ matches nothing fails. `-Dtest-rebuild` forces fresh Zig test compilation.
+- `snap -- --record=DIR` writes snapshots without touching goldens,
+ `snap -- --update` replaces goldens, `snap -- --no-retry` makes the first
+ failure decisive. Snapshot scripts can use `snap9p` to capture core cells
+ through 9P.
+- `zig build monkey -Dplatform=tty -- <seeds> [steps] [--from=N] [--out=DIR]
+ [--keep]` writes one random script per seed, runs it through pardes-snap
+ and keeps any script that panics (in `/tmp/pardes-monkey` by default); a
+ seed always makes the same script. Each crash found gets a fix and a
+ regression script in `test/snapshots/`.
+- `zig build hxdiff -- --strict cases.jsonl reference.jsonl [waivers.jsonl]`
+ runs custom differential cases; without `--strict` and a reference it only
+ emits results. Live helix steps take `-Dhelix-harness=<path>`,
+ `HX_HARNESS`, or `hx-harness` on PATH.
+- Benchmarks (`perf`, `pdf-bench`, `pdf-scroll-bench`, `pdf-sections-bench`,
+ `lspbench`, `fs-bench`) accept `-- --json`; measure with
+ `-Doptimize=ReleaseFast`. `perf -- --base old.json` refuses reports whose
+ build metadata differs.
+- `zig build history -- run 'ancestors(@, 2)' DIR -- zig build unit-test`
+ records a command's output and cold/warm runtimes across revisions (`jj
+ status` first: pending edits are not snapshotted); `history -- compare
+ a.json b.json 1.20` fails past that runtime ratio (`--snapshots`,
+ `--benchmarks` for stricter checks). Output directories are never
+ overwritten.
+- `python3 -B test/agent_session.py <pardes> --ready 'text' --min-rows N --
+ command args` drives an interactive command in a private shell and checks
+ it through 9P (`--gui-grid` for GUI builds).
## Documentation
Start with `src/panes.zig` (and the pane kinds it names, `src/File.zig`,
`src/Terminal.zig`, ...), `src/layout.zig`, and `src/fs.zig` for ownership and
operations, and `src/pardes.zig` for input, with one file per thing beside it
-(`edit.zig`, `normal.zig`, `look.zig`, `exec.zig`, `mouse.zig`, ...). `docs/design.typ` is the architecture
-sketch; `docs/design.pdf` is a retained rendering/search fixture and may lag it.
+(`edit.zig`, `normal.zig`, `look.zig`, `exec.zig`, `mouse.zig`, ...).
| file | subject |
|---|---|
-| `docs/design.typ` | architecture: the seams, the data model, the build graph |
-| `docs/detached.md` | one core, many frontends, over a unix socket |
-| `docs/config.md` | build options and runtime configuration |
-| `docs/tags.md` | editable workspace, column and pane tags; filename drafts |
-| `docs/selections.md` | the normal-mode selection model and how it differs from helix's |
-| `docs/fs.md` | default 9P service, Look resolution, and named mounts |
-| `docs/lsp.md` | in-process ZLS and external language servers |
-| `docs/lsp-evaluation.md` | why that backend, measured against the alternatives |
-| `docs/helix-keys.md` | the helix-compatible key model and its differential suite |
-| `docs/macos.md` | the native macOS shell, its bundle and its signing |
-| `docs/web.md` | the browser shell |
-| `docs/ghostty-macos-notes.md` | notes on the ghostty dependency |
-| `docs/ideas.typ` | scratch notes; nothing compiles it, and it says so |
+| `docs/fs.md` | the 9P control filesystem: every file, error and limit |
+| `docs/config.md` | settings, the startup file, dumps, build options |
+| `docs/tags.md` | tags, columns, and where new panes go |
+| `docs/detached.md` | one core, many frontends |
+| `docs/v9fs.md` | Tty9p: a terminal with the tree kernel-mounted |
+| `docs/cloud9.md` | the 9P library and the posted-9P registry |
+| `docs/selections.md`, `docs/helix-keys.md` | the normal-mode model and the helix key map |
+| `docs/themes.md`, `docs/effects.md` | themes and visual effects |
+| `docs/lsp.md` | language servers |
+| `docs/web.md`, `docs/macos.md` | the browser and macOS shells |
+| `docs/design.typ` | architecture sketch (`docs/design.pdf` is a test fixture and may lag it) |
+| `docs/divergences.md`, `docs/open-questions.md` | bookmarks off `main`; undecided questions |
+| other `docs/*.md` | design and research notes (`render-pipeline`, `lsp-evaluation`, `ui-review`, ...) |
-`next-steps.txt` is a wishlist with a status header saying which items have
-shipped; `transactions.txt` records one open structural gap against helix, and
-says which waiver proves it is still open.
+`next-steps.txt` is a wishlist with a status header; `transactions.txt`
+records one open structural gap against helix and the waiver that proves it.
## Layout
```
src/ core events (pardes.zig), panes, edit, look, exec, layout, fs, syntax
+src/ninep/ the 9P control tree
src/detached/ the wire, the detached core, the frontend client
src/lsp/ ZLS and external language-server backends
test/ harnesses, snapshot goldens, helix cases
diff --git a/build.zig b/build.zig
index e9e64056..a69327ae 100644
--- a/build.zig
+++ b/build.zig
@@ -864,11 +864,65 @@ pub fn build(b: *std.Build) void {
\\}
\\
;
+ // vaxis's render, patched the same way: a blank whose ink alone
+ // changed is not sent, since a blank's ink shows nowhere. A fade
+ // (InactiveDim on a focus change) sent every blank of every pane it
+ // touched again: 7 KB a click for three shell panes. The terminal
+ // keeps the old ink there, and so does vaxis's last frame.
+ const render_path = vaxis_dep.path("src/Vaxis.zig").getPath(b);
+ var render_src = std.Io.Dir.cwd().readFileAlloc(io, render_path, b.allocator, .limited(1 << 20)) catch @panic("read vaxis Vaxis.zig");
+ const render_anchor =
+ \\ if ((!self.refresh and
+ \\ last.eql(cell) and
+ ;
+ if (std.mem.count(u8, render_src, render_anchor) != 1) std.debug.panic("vaxis's Vaxis.zig changed at `{s}`: redo its patch in build.zig", .{render_anchor});
+ render_src = std.mem.replaceOwned(u8, b.allocator, render_src, render_anchor,
+ \\ if ((!self.refresh and
+ \\ (last.eql(cell) or blankInkOnly(&last, &cell)) and // pardes's patch (its build.zig)
+ \\
+ ) catch @panic("OOM");
+ // And the secondary cursors count as changed when they differ, not
+ // when they are the same: read the other way round, every render
+ // started a frame, ~40 bytes to the terminal with nothing changed.
+ const secondary_anchor = " std.meta.eql(self.screen.cursor_secondary, self.state.cursor_secondary);\n";
+ if (std.mem.count(u8, render_src, secondary_anchor) != 1) std.debug.panic("vaxis's Vaxis.zig changed at `{s}`: redo its patch in build.zig", .{secondary_anchor});
+ render_src = std.mem.replaceOwned(u8, b.allocator, render_src, secondary_anchor, " !std.meta.eql(self.screen.cursor_secondary, self.state.cursor_secondary); // pardes's patch (its build.zig)\n") catch @panic("OOM");
+ const blank_ink =
+ \\
+ \\/// pardes's patch (its build.zig): a blank cell (a space, no underline,
+ \\/// strike or reverse) that differs from the last frame's blank in its
+ \\/// ink alone, which shows nowhere.
+ \\inline fn blankInkOnly(last: *const InternalScreen.InternalCell, cell: *const Cell) bool {
+ \\ if (!(cell.char.grapheme.len == 1 and cell.char.grapheme[0] == ' ')) return false;
+ \\ if (last.default or cell.default) return false;
+ \\ if (!(last.char.items.len == 1 and last.char.items[0] == ' ')) return false;
+ \\ const s = cell.style;
+ \\ if (s.reverse or s.strikethrough or s.ul_style != .off) return false;
+ \\ if (!std.mem.eql(u8, last.uri.items, cell.link.uri)) return false;
+ \\ // Field by field: Style.eql is slow, and this runs for every blank
+ \\ // a scrolling shell moves.
+ \\ const l = last.style;
+ \\ return sameColor(l.bg, s.bg) and sameColor(l.ul, s.ul) and l.ul_style == s.ul_style and
+ \\ l.bold == s.bold and l.dim == s.dim and l.italic == s.italic and l.blink == s.blink and
+ \\ !l.reverse and !l.strikethrough and l.invisible == s.invisible;
+ \\}
+ \\
+ \\inline fn sameColor(a: Cell.Color, b: Cell.Color) bool {
+ \\ return switch (a) {
+ \\ .default => b == .default,
+ \\ .index => |i| b == .index and b.index == i,
+ \\ .rgb => |rgb| b == .rgb and rgb[0] == b.rgb[0] and rgb[1] == b.rgb[1] and rgb[2] == b.rgb[2],
+ \\ };
+ \\}
+ \\
+ ;
const files = b.addWriteFiles();
_ = files.add("Parser.zig", b.fmt("{s}{s}", .{ src, cursor_report }));
+ _ = files.add("Vaxis.zig", b.fmt("{s}{s}", .{ render_src, blank_ink }));
_ = files.addCopyFile(vaxis_dep.path("src/widgets/terminal/Parser.zig"), "widgets/terminal/Parser.zig");
- // Suffixes: this leaves out both Parser.zig files, written above.
- _ = files.addCopyDirectory(vaxis_dep.path("src"), "", .{ .exclude_extensions = &.{"Parser.zig"} });
+ // Suffixes: this leaves out both Parser.zig files and Vaxis.zig,
+ // written above.
+ _ = files.addCopyDirectory(vaxis_dep.path("src"), "", .{ .exclude_extensions = &.{ "Parser.zig", "Vaxis.zig" } });
vaxis_mod.root_source_file = files.getDirectory().path(b, "main.zig");
}
@@ -1029,7 +1083,7 @@ pub fn build(b: *std.Build) void {
}
// A detached session compiles its attached GUIs' Shadertoy files
- // (shader_build.zig), whichever shell it was built for.
+ // (ShaderBuild.zig), whichever shell it was built for.
if (gui_shell_mod != root_mod)
root_mod.addAnonymousImport("post-prefix.glsl", .{ .root_source_file = b.path("shaders/post/prefix.glsl") });
@@ -1580,6 +1634,18 @@ pub fn build(b: *std.Build) void {
run_monkey9p.setCwd(b.path("."));
run_monkey9p.has_side_effects = true;
b.step("monkey-9p", "random 9P operations checking the documented rules (-- [--seed N]... [--steps M] [--replay F] [--shrink F] [--no-shrink])").dependOn(&run_monkey9p.step);
+ // The GUI's effects under random input in a hidden test window, each
+ // Motion x Lift x Bloom (test/gui_monkey.py): a gate, not part of
+ // fs-test, since it needs a Wayland display.
+ const monkey_gui_step = b.step("monkey-gui", "random input to hidden GUI test windows across the effect settings (-Dplatform=gui; -- [--seed N] [--steps M])");
+ if (platform == .gui) {
+ const run_monkey_gui = b.addSystemCommand(&.{ "python3", "-B", "test/gui_monkey.py" });
+ run_monkey_gui.addArtifactArg(exe);
+ if (b.args) |args| run_monkey_gui.addArgs(args);
+ run_monkey_gui.setCwd(b.path("."));
+ run_monkey_gui.has_side_effects = true;
+ monkey_gui_step.dependOn(&run_monkey_gui.step);
+ } else monkey_gui_step.dependOn(&b.addFail("monkey-gui needs -Dplatform=gui").step);
const run_monkey9p_smoke = b.addSystemCommand(&.{ "python3", "-B", "test/monkey9p.py" });
run_monkey9p_smoke.addArtifactArg(exe);
run_monkey9p_smoke.addArg("--smoke");
diff --git a/docs/cloud9.md b/docs/cloud9.md
index 7365bf65..cd92fd05 100644
--- a/docs/cloud9.md
+++ b/docs/cloud9.md
@@ -1,107 +1,46 @@
-# cloud9 integration
+# cloud9
-The published `cloud9` package owns the base 9P2000 wire format, client and server
-connections, and TCP/Unix/QUIC transports. `build.zig.zon` pins a commit from
-`[email protected]:~gbrls/cloud9`, so `zig build` fetches it into `zig-pkg/` like every
-other dependency; no sibling checkout is required. Re-pin with
-`zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`, and swap in
-`.cloud9 = .{ .path = "../cloud9" }` while editing both packages at once.
+The `cloud9` package owns the 9P2000 wire format, client and server
+connections, the file-server engine and the Unix/TCP/QUIC transports.
+`build.zig.zon` pins a commit from `git.sr.ht/~gbrls/cloud9`, fetched into
+`zig-pkg/` like any dependency. Re-pin with
+`zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`, or
+use `.cloud9 = .{ .path = "../cloud9" }` while editing both.
-The file-server engine (fids, jobs, parking, flush, hangup) is cloud9's
-`fs.Server`; the control tree in `src/ninep/` is its backend, using cloud9's
-`fs.Req`, `fs.Op`, `fs.Status`, `fs.E` and `fs.ReplyWith` (extended with the
-editor's reply payload locator). `src/9p.zig` names the editor's and the
-board's `fs.Options` and re-exports the wire names the transports use.
-Mounting, Unix namespace discovery and permissions, the editor event loop,
-connection limits, and exported tree policy remain here. The Unix and TCP
-listeners run on cloud9's `serve.Runner` (`std.Io`: an accept task per
-listener, a reader and a writer task per connection, four slots); its handler
-answers every backend request on the connection's task, taking the editor's
-turn with the core (`pardes.turn`) while the editor waits for input or is out
-in a syscall. A request that would change a pane while the editor is mid-step
-is parked with `Status.again` -- the engine parks reads, writes, opens,
-truncations, clunks and removes -- and retried by `Runner.wakeAll` when the
-editor next rests. A read with nothing yet parks the same way, but is never
-retried for news: the core holds it (`ctlfs.hold`) and `answerHeld` answers
-it through the ticket `Conn.hold` gave its park, with `Conn.answerWith`,
-which makes the answer only while that very park still waits (not flushed,
-its fid not clunked, not out being retried) and under the same lock. `src/9p_quic.zig` selects the existing `pardes-9p` ALPN for
-cloud9's optional OpenSSL transport; QUIC still runs on the poll loop in
-`src/9p_io.zig` on the editor's thread, since cloud9's QUIC adapter is
-nonblocking-descriptor based rather than `std.Io` based.
-The standalone protocol/GPIO tests import the same module. The separate
-`05-zig-p4` build also supplies cloud9 for the GPIO firmware entry.
+- The engine (fids, jobs, parking, flush, hangup) is cloud9's `fs.Server`;
+ the control tree in `src/ninep/` is its backend. `src/9p.zig` names the
+ editor's and the board's `fs.Options` (msize 8192, 256 fids).
+- Unix and TCP listeners run on cloud9's `serve.Runner` (`std.Io`: an accept
+ task per listener, a reader and a writer task per connection, 16
+ connections). Requests are answered on the connection's task, which takes
+ the editor's turn (`pardes.turn`) while the editor waits for input or is
+ out in a syscall. A request that would change a pane while the editor is
+ mid-step parks (`Status.again`) and is retried when the editor rests. A
+ read with nothing to answer yet is held by the core and answered through
+ the ticket `Conn.hold` gave it, only while that park still waits.
+- QUIC (`src/9p_quic.zig`, ALPN `pardes-9p`) still runs on the editor's
+ poll loop in `src/9p_io.zig`, since cloud9's QUIC adapter is
+ nonblocking-descriptor based.
-Run `zig build 9p-test` for the engine configurations and
-`zig build 9p-io-test -Dquic=true` for native transport/client integration. Run cloud9's `zig build test`,
-`transport-test`, `quic-test -Dquic=true`, `fuzz`, and `differential` steps for the
-shared implementation. See cloud9's `docs/validation.md` for recorded runs and
-known test-environment limits.
-
-Invalid framing now terminates a server connection. Cloud9 also checks reply
-counts and reserves tags until flush completion. Client metadata is slightly
-larger to track those reservations, and its bounds tests reflect that fixed cost.
+Tests: `zig build 9p-test` (engine configurations), `zig build 9p-io-test
+-Dquic=true` (transports and client). cloud9's own `zig build test`,
+`transport-test`, `quic-test -Dquic=true`, `fuzz` and `differential` cover
+the shared code.
## The posted-9P registry
-`$XDG_RUNTIME_DIR/9p` is this machine's `/srv`: a server posts itself in it
-under a name, and clients dial names rather than paths. cloud9 owns both
-sides (`cloud9.post`), and `9ns --mntgen` mounts the whole registry at
-`/mnt/9p` for programs that want it as a filesystem. pardes's only part in
-it is to put itself there.
-
-**Serving.** A listening editor advertises itself at
-`$XDG_RUNTIME_DIR/9p/pardes/<name>`, a symlink to the socket it already
-binds. One directory for the program, one entry per editor, so several
-editors group instead of crowding the registry root — the layout zmx posts
-its sessions under. The socket itself does not move: adopting the registry
-only advertises. Only the runtime-directory socket posts; an instance that
-fell back to `~/.local/state/pardes` stays out of the user's registry, the
-way a private `ZMX_DIR` does for zmx. Stopping unposts, and only while the
-entry is still ours, so a name another editor has since claimed is never
-unlinked.
-
-An exit that cannot run any code of its own — an aborted test, a kill, a
-crash — leaves its entry and its socket behind, so posting first sweeps the
-group: every entry that is a symlink and whose socket answers a connect with
-a definite ECONNREFUSED is unlinked, along with the socket it points at when
-a `stat` agrees that is a socket of ours. Anything that is not a symlink is
-somebody else's, and any other answer — connected, busy, refused permission,
-a surprise — counts as live, because uncertainty belongs to the server that
-owns the socket rather than to the sweeper. That is `cloud9.post.Probe`'s
-classification, repeated in `src/9p_io.zig` only because `post.probe` is raw
-Linux syscalls and pardes also builds for darwin.
-
-**Consuming.** Nothing. `9ns --mntgen` mounts the whole registry at
-`/mnt/9p`, and an interactive fish already self-wraps in one, so a pardes
-started from a terminal sees every posted service as ordinary files —
-`/mnt/9p/harness/active/...` is read with the same code that reads any other
-path. Teaching pardes to dial the registry itself would put discovery in a
-second place for no gain: mounting is the client's job and 9ns is the
-client. `--mount=<name>=<dial>` keeps meaning exactly what it always did,
-and a dial keeps resolving exactly as it always did — a bare name is another
-pardes session, and anything with a slash is a path, relative ones included.
-
-The one case that is not free: a pardes started outside a mntgen mount has
-no `/mnt/9p`. That is 9ns's problem to solve — by being in the namespace —
-not a reason for pardes to carry its own registry client.
-
-### What this diverges from, deliberately
+`$XDG_RUNTIME_DIR/9p` is this machine's `/srv`: servers post themselves
+there by name, and `9ns --mntgen` mounts the whole registry (default
+`/mnt/9p`). A pardes whose socket is in the runtime directory posts
+`$XDG_RUNTIME_DIR/9p/pardes/<name>`, a symlink to its socket (one directory
+per program, as zmx posts its sessions); a socket that fell back to
+`~/.local/state/pardes` is not posted. Stopping unposts the entry if it is
+still ours. Posting first sweeps the group: an entry that is a symlink whose
+socket refuses a connect (ECONNREFUSED) is removed with its socket; anything
+else counts as live.
-* **pardes binds its own socket; it does not post through `cloud9.post`.**
- `post` would give us its hardened claim protocol (temp-bind plus atomic
- rename under a lock) instead of the stale-socket retry in `listen`, but it
- claims *flat* names only: `legalName` rejects `/`, and `claimName` derives
- its lock directory by stripping `/9p` from the registry path, so a name
- inside a subdirectory cannot go through it. zmx hand-rolls the same
- symlink for the same reason. Unifying them means teaching `post` a group —
- passing the lock directory in rather than deriving it — and that is a
- change to adversarially-hardened code, not a rename.
-* **The registry entry is a symlink, not the socket.** A reader that expects
- every registry entry to be a socket must `stat` following symlinks.
- `9ns --mntgen` and `cloud9.post.dial` both do.
-* **Dialing is untouched.** An earlier draft taught `resolve` to fall back
- to the registry for a bare name and to read `<group>/<name>` as a
- subdirectory entry. Both were reverted: the second reinterpreted relative
- dials, which are a feature, and the first duplicated what 9ns already
- does. `src/9p_io.zig`'s `resolve` is byte-identical to what it was.
+pardes does not dial the registry: `9ns` is the client, and `--mount` dials
+resolve as always (a bare name is a pardes session, anything with a slash a
+path). pardes binds its own socket instead of posting through `cloud9.post`,
+because `post` takes only flat names and pardes posts into a group
+directory. A reader of the registry must `stat` through the symlink.
diff --git a/docs/config.md b/docs/config.md
index 349814a6..f25c8746 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1,79 +1,20 @@
-# Startup configuration
+# Configuration
-Native pardes builds use a per-user `pardes` configuration directory. Its
-main command file is named `init`:
+## The startup file
-- Unix: `$XDG_CONFIG_HOME/pardes/init`, falling back to
- `~/.config/pardes/init`.
-- macOS: `$XDG_CONFIG_HOME/pardes/init` when that variable is set, otherwise
- `~/Library/Application Support/pardes/init`.
-- Windows: `%LOCALAPPDATA%\pardes\init`, with
- `%USERPROFILE%\AppData\Local\pardes\init` as the fallback.
-
-On the two unixes `XDG_CONFIG_HOME` counts only when it is ABSOLUTE, as the
-XDG base-directory specification requires; an empty or relative value falls
-back to the home-directory form (`config.User.path`, and the test beside
-it). Windows never consults it. An `init` that does not fit the `max_bytes`
-read limit — 1 MiB, `config.User` in `src/config.zig` — or that cannot be read at all is
-treated as no file: `load` takes the `readFileAlloc` error and keeps going. The
-path still resolves, because "nothing is there yet" is the answer `Config`
-exists to give. There is one case with no path at all: a native launch with no
-`HOME` set (or, on Windows, neither `%LOCALAPPDATA%` nor `%USERPROFILE%`),
-which `Config` reports as `no per-user config path`.
-
-`Config` (`SPC f c`, or the word executed anywhere) opens one refreshable
-`+Config` pane. It reports the startup path and every live config-like value:
-theme, colors, focus tint, syntax weight, wrapping, tag position, debug mode, the requested shell and the
-executable actually resolved at the last spawn, requested/effective GUI font
-and size, tagline scale, window opacity, ligatures (SDL GUI only), panel transition,
-scene effects, hover delay, platform, native-image support, and (on SDL) whether
-the executable uses live-built shaders or the paired prebuilt shader snapshot.
-Platform-dependent rows say `unsupported` instead of looking like an off or
-empty supported setting. The fields of `config.Runtime.Capabilities` gate
-them and are stated once as plain data in `builtins.capabilities`:
-`font_picker` is the SDL GUI and
-native macOS only, `scene_shaders` the same two, `panel_transitions` every
-hosted shell, `window_opacity` the SDL GUI and macOS, `window_blur` macOS
-only, `ligatures` the SDL GUI only, and `tagline_font_size`
-everything but the TTY and the board. `ligatures` is the exception to
-`unsupported`: where it is off, `Ligatures` is not a builtin at all and
-`Config` has no row for it. So
-the TTY reports Font, TaglineSize and the scene shaders as unsupported; the
-browser reports Font, panel transitions and the scene shaders as unsupported,
-and its TaglineSize row reads `82 (build-time only)` — tagline font size is
-its own capability precisely because GUI font SELECTION is native-only while
-the browser still applies the compiled percentage to its DOM glyphs.
-The startup path is printed whether or not a file exists — that is usually when
-it is most useful — and is ordinary selectable text, so a right click on it
-opens the file. Shell follows the same requested/effective/pending model as
-Font. `Default shell` is the one used while no `Shell` is set: `$SHELL`, the
-user's login shell, else `/bin/sh` (also when `$SHELL` names nothing
-executable); an explicit `Shell` overrides it. `Shell <name or path>` is
-refused unless it names an executable file (`Shell: shell "x" not found (...)`, or
-`Shell: not a shell: /etc is a directory`; a bare name is looked for in the
-usual bin directories), as `Tty <shell>` is, and a
-bare `Shell` goes back to the default. The root ctl reads `Shell <the one the
-next terminal runs>`. `Shell
-effective (last spawn)` is the executable the native host really chose after
-installation lookup and fallback. A changed request remains pending until a
-terminal is spawned, because the core does not resolve native executables.
+Native builds read one command file, `init`, from the per-user `pardes`
+configuration directory:
-The mutable global values live together in the plain `config.Runtime` record.
-One plain capability record gates the setting registry, leader table,
-`EffectCode`, and report; the compile-time setting table generates both setter
-builtins and their `Config` rows. Exhaustive checks require every table-backed
-toggle, transition, and scene-effect switch to occur exactly once, so those
-generated setting builtins cannot quietly lose their query row or leave a
-renderer switch unnamed. Manual pane-local actions remain with their payload (for
-example an image tag reports its renderer choices); they are not global
-configuration.
+- Unix: `$XDG_CONFIG_HOME/pardes/init` (only an absolute `XDG_CONFIG_HOME`
+ counts), else `~/.config/pardes/init`.
+- macOS: `$XDG_CONFIG_HOME/pardes/init` when set, else
+ `~/Library/Application Support/pardes/init`.
+- Windows: `%LOCALAPPDATA%\pardes\init`, else
+ `%USERPROFILE%\AppData\Local\pardes\init`.
-The browser build has no local user-config path and does not load this file.
-(Nor does it have `Font`, the effect builtins or `EffectCode`, a language
-backend, or ptys of its own — see `docs/web.md`.)
+The browser build has none. A file over 1 MiB or unreadable counts as absent.
-The format is one existing builtin command per line, using the same spelling
-and argument parsing as commands executed inside pardes:
+Each line is one builtin, spelled as it would be executed in pardes:
```text
Theme orchard
@@ -83,620 +24,171 @@ Shell zsh
Wrap
```
-A line matches a builtin whose name takes NO argument only as that whole word:
-`Kill` runs, `Kill something` does not. A builtin that takes one
-(`takes_arg` in `src/builtins.zig`, or a `settings` row whose action is
-`shell`, `theme`, `font`, `tagline_size` or `window_opacity` in `config.Runtime.settings`) takes
-everything after the name as the argument. On a native build that is `Theme`,
-`ThemeFile`, `Font`, `TaglineSize`, `Shell`, `Save`, `Restore`, `Attach`,
-`Mount`, `Unmount`, `Find`, `Grep`, `Rename`, `WsSymbols`, `Look`, `Exec`,
-`Msg` and `EffectCode`.
-The SDL GUI also has `WindowOpacity`, which takes one argument.
-(`Peek`, `Poke`, `Hexdump` and `Gpio` take one too, but they exist only where
-`builtins.Board.enabled` holds, and that build has no config file.)
-
-`Theme <name>` wants one of the names in the compiled ring. Do not derive the
-spelling — read it off `Themes` (`SPC t t`), which lists every one as the
-exact `Theme <name>` line that selects it. `slug` in `tools/gen_themes.zig`
-lowercases, folds punctuation runs to a single `_` and then TRIMS leading and
-trailing ones (`penumbra+.toml` is `penumbra`, not `penumbra_`), and every
-variant read out of a zed `.json` gets `_zed` on the end so it cannot collide
-with a helix theme of the same name — zed's "Ayu Mirage" is `ayu_mirage_zed`
-and `ayu_mirage` is helix's `ayu_mirage.toml`. The suffix goes on all of them
-rather than only the eight that clash today, so a name cannot move when either
-project gains or loses a file. A name that is not in the ring is ignored.
-
-The fifteen [native Pardes themes](themes.md) lead the ring: `orchard` (the
-default), `dusk`, `ink`, `paper`, `daybreak`, `atelier`, `forge`, `lagoon`,
-`solarium`, `spectrum`, `harvest`, `clay`, `forge_black`, `forge_soft`, and
-`orchard_black`, then `acme` and `lapis`. `Themes` lists them first under
-Pardes themes. They add coordinated
-focus, search, diagnostic and terminal colors; `ink` and `daybreak` are high
-contrast dark and light options. After them come the
-[faithful ports](themes.md#faithful-ports) of well-known themes, a section per
-family, then the legacy `helix` and `dark`, then everything imported. Where a
-port takes an imported theme's name (`dracula`), the imported one gains
-`_helix` (`dracula_helix`), as zed's carry `_zed`. `NextColor` walks the same
-ring in the same order.
-
-`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.
-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).
-`BootShell keep` (the default) leaves a shell alone when a document is dragged
-into the left column beside it, whatever it holds; `BootShell replace` closes
-such a shell there when it is the column's only one and nobody has typed
-into it (no scrollback, cursor still on the first prompt line), the boot's
-placeholder giving its rows to the document. Bare `BootShell` flips it, as
-bare `Placement` does: a setting that chooses among words (`on`/`off`
-switches, `Placement`, `BootShell`, `Crt`, `Bloom`, `Vignette`, `Grain`, `Lift`, `Motion`,
-`ShaderAnimation`) steps to its
-next value when written bare, as its word clicked in a tag does, and a value
-it does not take is refused with the values it takes, which `/commands` also
-lists.
-`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
-messages a builtin chooses to write.
-`MessageAnimation` toggles how a message comes and goes; it is on by default.
-A message eases down into its row, fast at first and settling at the end, its
-colour whole by half way (graphical frontends slide it out from under the
-tagline as it fades up; a terminal only fades it in), stays until the next key or click as it
-always has, then lingers for `MessageLinger` milliseconds (default 800) before
-it dissolves into the page. `MessageFall` (default 180) and `MessageDissolve`
-(default 150: leaving is quicker than arriving) set how long the fall and the dissolve take, also in
-milliseconds; each timing is at most 60000, and `Config` reports all three. `MessageLinger 0` with
-`MessageAnimation off` restores the old behaviour, a message cleared by the
-very input that follows it. An updated line on a row already showing one swaps
-its text in place rather than falling again.
-`SyntaxBold` toggles bold syntax keywords; it is off by default. These
-settings are shared by GUI and TTY, and `Config` reports their current
-states. Like `Colors` and `Wrap`, these commands take no argument and invert
-the current value. A `FocusTint` line in a fresh startup configuration disables
-the tint; a `SyntaxBold` line enables the stronger keyword weight. Those two
-appearance controls leave focus, selections and editing behavior unchanged.
-
-`Ligatures` (SDL GUI only) toggles a font's programming ligatures, such as
-Maple Mono's `->` and `!=` drawn across their cells; it is on by default.
-Off, every cell draws its own glyph, exactly as a font without ligatures does.
-`Ligatures on` and `Ligatures off` set it explicitly. The native macOS shell
-draws CoreText ligatures of its own, which this setting does not reach.
-
-`TreeContext` toggles sticky declaration headers for the current source pane.
-It is off by default and appears in the default pane tag when a tree-sitter
-grammar supports that file. `TreeContext on` and `TreeContext off` set it
-explicitly. Scrolling inside a function, type or module keeps its enclosing
-declarations above the body, with source line numbers, syntax colors and a
-subtle background tint. Clicking a header moves the cursor there without
-scrolling; scrolling upward reveals that source line as the headers recede.
-`Dump` and `Restore` preserve the setting per pane. Customized tags retain
-their text; the command can still be executed from any source pane.
-
-`TreeContextTagStyle` toggles the experimental tagline treatment for those
-headers and is on by default. In graphical frontends it uses the tagline font,
-line height and thin border. A matching separator marks gaps between the
-headers' source lines. Turning it off restores body-sized context rows.
-`TreeContext` itself still defaults off; this appearance option does not enable
-it. `Config` reports the appearance option, and dumps preserve it.
-
-Search, Grep and LSP location-result panes include `LocationsConfig` in their
-default tags. Custom tags keep their edits.
-
-Location highlighting is enabled by the command that produces a location list.
-Plain reports, including `LocationsConfig`, keep ordinary text colors even
-when their text resembles a location. `Mini` keeps its own syntax colors.
-LSP references and goto results highlight the exact symbol span supplied by
-the server, using the search-result colors. Source indentation is retained so
-those byte ranges stay aligned; context and descriptive labels remain unmarked.
-
-`LocationsConfig` prints the current settings for Search, Grep and LSP location
-results in an output pane. Execute the printed line to apply it again, or
-supply just the fields to change:
-
-```text
-LocationsConfig context:5 tscontext:on tslocations:off layout:stacked
-```
-
-- `context` is the number of source lines above and below each match (default
- `0`). Overlapping context is shown once. These neighboring lines omit their
- locations and keep their code aligned with the matching result.
-- `tscontext` includes enclosing tree-sitter declaration headers in source
- order (default `off`). Preceding neighboring context stops at the outermost
- enclosing declaration, keeping unrelated lines above it out of the result.
-- `tslocations` shows a location on the first line of each declaration header
- (default `on`); continuation lines keep the same alignment without repeating
- the location. Declaration headers use the same muted color as locations, with or
- without their locations visible.
-- `layout:stacked` (default) puts each location on its own line, followed by its
- source preview. Hidden context locations do not add an empty address line.
- Use `layout:inline` to put locations beside the source instead.
+A builtin that takes no argument matches only as the whole line (`Kill` runs,
+`Kill something` does not); one that takes an argument takes the rest of the
+line. Blank, unknown, malformed or failing lines are ignored silently and do
+not stop later ones. Text that is no builtin is not run as a shell command
+(`Exec ...` still is). Key bindings are compile-time choices in
+`src/config.zig`; `init` does not remap them.
-In inline layout, result locations share padding in groups of eight matches, so a long path only
-widens its own group. Context does not count toward the eight; at group boundaries,
-it aligns with the nearer match (ties stay with the preceding group). Source
-indentation is preserved. All visible locations start flush left; `n` and `N`
-still stop on matches.
-With `tscontext` enabled, asterisks after a match location show its declaration
-depth (for example, `main.zig:42 ***`). Ordinary neighboring source lines retain
-syntax colors; declaration headers do not.
-A declaration context line ends with ` ...` when source lines are omitted
-before the next displayed row from that file.
-Source analysis is reused across result queries while the source bytes stay the
-same. The cache retains at most 64 files and 64 MiB; it checks current buffer or
-filesystem contents on each refresh.
-Open file buffers supply context from their current edits. Missing files still
-leave the original result available. These settings apply to subsequent result
-generation and survive `Dump`/`Restore`. Invalid fields reject the entire
-update; if a field appears twice, its last value wins.
+`Config` (`SPC f c`) opens a `+Config` pane with the startup path (a right
+click opens it) and every live setting; a setting the frontend cannot show
+reads `unsupported`. The root `ctl` file reads the settings back in the
+words a write takes ([fs.md](fs.md#the-root-ctl)).
-`WindowOpacity <percent>` controls the opacity of everything in the SDL window
-except text and the cursor, which stay fully opaque. Use `WindowOpacity 85`
-to see the desktop through the editor, or `WindowOpacity 100` to restore full
-opacity (the default). The argument must be a whole number from
-0 through 100; missing or invalid values
-leave the setting unchanged. At zero only text and the cursor remain visible.
-Add the command to `init` to persist it.
+## Settings
-The same opacity applies to editor and embedded terminal backgrounds, UI
-chrome, borders, scrollbars, gutters, and images. Overlapping non-text drawing
-does not make those areas more opaque. Regular text, syntax colors, tagline
-text, terminal glyphs, and the cursor keep their normal opacity. This does
-not blend foreground colors into their cell backgrounds. The TTY
-and other non-SDL hosts do not emulate this effect; their `Config` report says
-`WindowOpacity unsupported`.
+A setting that chooses among words (`on`/`off` switches, `Placement`,
+`BootShell`, `Crt`, `Bloom`, `Vignette`, `Grain`, `Lift`, `Motion`,
+`ShaderAnimation`) steps to its next value when given bare, as its word
+clicked in a tag does; a value it does not take is refused, naming those it
+does. `/commands` lists every builtin and its values.
-On native Wayland, Pardes uses an alpha-capable transparent surface. It does
-not use whole-window opacity protocols such as `wp_alpha_modifier_v1`, because
-those would also fade the text. Presentation uses the existing GPU offscreen
-renderer followed by a readback and SDL renderer upload per presented frame,
-which adds rendering cost. Other SDL drivers can report background transparency
-as unsupported unless their rendering configuration is alpha-capable.
-If the rendering backend cannot apply the request, Pardes reports the error
-and keeps the last successfully applied opacity. `Config` reports the
-percentage and marks a request as pending until the SDL host handles it.
-
-## Crash records
-
-A panic appends to `crashes` in that same directory, beside `init`, and only
-then prints to stderr (`src/crash.zig`, wired into the panic handlers in
-`main.zig` and — because the macOS build roots there — `macos.zig`). stderr is
-the one place this program cannot keep a trace: in the TTY shell stderr IS the
-screen, so the trace lands on a grid the terminal is being reset out of; the SDL
-and AppKit shells have no terminal at all; and a `--detach` session's stderr
-goes wherever its launcher left it. The file is appended, never rewritten, and
-each record is two lines — build metadata, then the panic message:
-
-```text
-pardes 0.0.2 (a1b2c3d) 2026-09-03T11:20:44Z linux-x86_64 pid 48812
-panic: index out of bounds: index 4, len 4
-```
-
-NO STACK TRACE, and that is a measured decision rather than an omission. The
-frames stay on stderr, where `std.debug.defaultPanic` prints them. Collecting
-them here instead HANGS the process: `writeCurrentStackTrace` called from a
-panic handler before `defaultPanic` has run wedges at 0% CPU, and
-`captureCurrentStackTrace` — which looks like the safe half — takes `SelfInfo`'s
-rwlock exclusively on its first call, so a panic inside the walk leaves that
-lock held and `defaultPanic` then waits on it forever. What makes `defaultPanic`
-survive the same hazard is its own private `panic_stage`, which nothing outside
-`std.debug` can reach. A crash that becomes a hang is worse than the crash, so
-this file keeps only what it can gather without asking the process any
-questions: which build, when, where, and what it said.
-
-Everything about it is best effort and silent: no config directory (a launch
-with no `HOME`) means no file, and a directory that cannot be created or opened
-leaves the panic exactly as it was before — stderr alone. The directory itself
-is created if it does not exist, because the user who never wrote an `init` is
-as likely as any other to hit a bug. One record at a time: two threads panicking
-at once would otherwise interleave into one buffer, so the second falls straight
-through to stderr. Only panics come here; a SIGSEGV is caught one level lower
-(`main.zig`'s `debug.handleSegfault`) and unwinding one needs the signal's saved
-CPU context.
-
-## Runtime theme files
-
-`ThemeFile <path>` loads one complete theme from a `.zon` file. An absolute
-path is used as written; a relative path is resolved from the `pardes`
-configuration directory, not from the process working directory. A typical
-layout is:
-
-```text
-~/.config/pardes/
-├── init
-└── themes/
- └── mine.zon
-```
-
-and the corresponding init line is:
-
-```text
-ThemeFile themes/mine.zon
-```
-
-After a successful load, hosts with document live reload watch the path with
-the same parent-directory mechanism, so in-place writes and editor-style
-rename-over saves reload the theme live. A malformed or incomplete save does
-not replace the last valid theme; fixing and saving the file applies the next
-valid snapshot. Selecting a compiled theme with `Theme <name>` or `NextColor`
-stops the custom-file watch.
-
-Execute `DumpThemes` to write every theme compiled into the executable to:
-
-```text
-<config directory>/themes/builtin/<name>.zon
-```
-
-The command replaces those generated reference files but leaves unrelated
-files alone. Copy one into `themes/`, rename it, change its `.name`, and use it
-as the starting point for a custom theme. The dumped file is also the complete
-format, and it is `pardes.Theme` serialised by `std.zon.stringify`: the theme
-name, thirteen required RGB roles (`sel_bg`, `sel_fg`, `tag_bg`, `tag_fg`,
-`box`, `box_dim`, `kw`, `str`, `num`, `comment`, `lineno`, `scroll_track`,
-`scroll_thumb`), nullable `bg`/`fg`, and either a 16-color RGB `palette` or
-`null`. Optional nullable RGB roles extend this format:
-`tag_active_bg`, `tag_active_fg`, `tag_name_fg`, `tag_active_name_fg`, `border`, `empty_col`, `lineno_active`, `search_bg`, `search_fg`,
-`diagnostic_error`, `diagnostic_warning`, `diagnostic_info`,
-`diagnostic_hint`, `tag_sel_bg`, `tag_rule`, `box_border` and `box_dirty`;
-`sweep_bg` and `sweep_fg` take three triples, and `rule_px` and `rail_px` a
-pixel count. Missing new roles use backward-compatible defaults, so
-previously exported files remain valid. [Theme customization](themes.md)
-describes each role and its fallback. RGB values are three-byte arrays, and
-hex literals are accepted. The original fields remain required; there is no
-inheritance or partial override layer.
-
-`tag_name_fg` gives the filename at the end of a pane's path a separate
-foreground. `tag_active_name_fg` can adjust that tint for active tags; it falls
-back to `tag_name_fg`. When both are omitted or `null`, the filename uses
-the corresponding tag foreground. All canonical themes use a different hue
-at similar perceived brightness to the surrounding text in both states.
-Directory text and tag commands retain
-their regular colors; selecting filename text uses the selection colors.
-Terminal tags use this color for `Tty`, which comes immediately after the path.
-
-`Filter` in a terminal's tag projects that pane's ANSI colors through the
-active theme, and does it in two stages. The default foreground and background
-roles are mapped FIRST, because ghostty-vt generates the whole 256-color
-projection from that pair; every other color follows, by reducing it to its
-nearest canonical xterm key and reading the key back out of the projection.
-
-That reduction compares RGB triples, so it knows about hue and nothing about
-the page — and the projection's cube corners ARE the two anchors, which is how
-a foreground used to end up painted the exact color of the paper behind it
-(`\x1b[38;2;255;255;255m` on acme's `#ffffea`, and the ANSI black a shell
-writes with `\x1b[30m` on either dark theme). So a foreground additionally has
-to keep `tty_filter_min_contrast` — a WCAG ratio, `1.5` by default — against
-the mapped background. One that cannot is not mapped: it takes whichever of
-the theme's own two anchors is still visible on that background. Backgrounds
-are exempt, since a background is the page the floor is measured against. Set
-the constant to `1.0` to accept every projected color, collapses included.
-
-`Shell <name>` sets the binary that the NEXT terminal pane execs; panes
-already open keep the shell they are running. A bare name is resolved against
-the handful of directories a shell actually lives in, not `$PATH`.
-
-On Linux, `Tty9p` (`SPC n 9`) starts that shell with a private kernel 9P mount,
-asking sudo inside the new terminal. `$PARDES_MOUNT` names the mountpoint.
-The installed `pardes-v9fs` helper lives beside the editor; development builds
-can set `PARDES_V9FS_HELPER` to its absolute path. See [v9fs.md](v9fs.md).
-
-Ctrl-B switches between raw TTY and editor mode. Plain Escape at a detected
-shell prompt hops back to the previous pane. Other keys, including Ctrl-O,
-Ctrl-W and modified Escape, belong to the child. Use `Mode` in the pane tag to
-return to editor mode in place. Desktop paste events still feed the terminal.
-
-The paste chords are the exception the window keeps. Ctrl-V types the yank
-register at the program, and Ctrl-Shift-V asks the desktop for its clipboard
-and types that; both go through the program's bracketed paste when it has
-asked for one. Neither reaches the child as a keystroke, so an application
-that would otherwise answer Ctrl-V by reading the system clipboard itself
-never gets the chance to read the wrong thing.
-
-`Font` and `Fonts` exist ONLY in the SDL GUI and native macOS builds — a
-terminal's font belongs to its emulator and a browser's to the page — so a
-`Font` line is one of the silently-ignored ones everywhere else. Both builds
-resolve the name by walking the font directories on every lookup, so a face
-installed a moment ago is findable.
-
-Use `Font <name>:<size>` to change face and size together, for example
-`Font MartianMono-NrRg:18` in the startup file or an editable tag. Fractional
-sizes such as `:18.5` are supported; the accepted range is 8–72 (pixels in SDL,
-points on macOS). `Font <name>` without a suffix preserves the current size.
-Invalid sizes or unknown faces leave the current font unchanged.
-
-The SDL GUI also has a tiny optional workspace-tag companion: `Pet cat`,
-`Pet frog`, or `Pet off` (the default). Its original pixel sprite walks and
-idles in the trailing blank area of the global tag only. It hides while that
-tag is being edited, when the tag is full, or when the font leaves too little
-room; it never replaces text, receives clicks, or appears in pane/column tags.
-Animation runs at ten steps per second, uses theme ink, and requires no image
-assets or shaders. Put `Pet cat` in the startup configuration to keep it;
-`Pet off` disables the companion and its animation. Other hosts ignore the
-startup command. `Config` reports the current choice in SDL.
+| setting | default | |
+|---|---|---|
+| `Theme <name>` | `orchard` | names as `Themes` (`SPC t t`) lists them ([themes.md](themes.md)); `NextColor` walks the ring |
+| `ThemeFile <path>` | | a `.zon` theme, relative to the config directory; reloads live when saved |
+| `FocusTint` | on | tint the focused pane's and column's tags |
+| `SyntaxBold` | off | bold syntax keywords |
+| `Verbose` | on | a builtin announces its name on the message row |
+| `MessageAnimation` | on | messages ease in and dissolve |
+| `MessageLinger`, `MessageFall`, `MessageDissolve` | 800, 180, 150 | milliseconds, at most 60000 |
+| `Placement acme\|pardes` | `acme` | where new panes go ([tags.md](tags.md#where-new-panes-go)) |
+| `BootShell keep\|replace` | `keep` | `replace` closes the untouched lone shell a dragged document lands beside |
+| `LookWord search\|list` | `search` | a looked-at word selects its next place, or lists all in `+Search` |
+| `Shell <name or path>` | `$SHELL`, else the login shell, else `/bin/sh` | the shell the next terminal runs; a bare name is looked for in the usual bin directories, not `$PATH`; bare `Shell` returns to the default |
+| `DumpDir <dir>` | `$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes` | where `Dump` writes; `~/` is home; bare returns to the default |
+| `TreeContext` | off | sticky declaration headers in a source pane (per pane, dumped) |
+| `TreeContextTagStyle` | on | draw those headers in the tagline style |
+| `LocationsConfig ...` | | Search, Grep and LSP result layout (below) |
+| `Wrap`, `Colors`, `Tagbottom`, `Debug` | | toggles |
+| `Font <name>[:<size>]`, `Fonts` | | SDL and macOS only; size 8-72 (pixels in SDL, points on macOS) |
+| `TaglineSize <1-100>` | 82 | tagline face, percent; SDL and macOS |
+| `WindowOpacity <0-100>` | 100 | SDL only: everything but text and the cursor |
+| `Ligatures` | on | SDL only; macOS draws CoreText's own |
+| `Pet cat\|frog\|off` | off | SDL only: a sprite in the workspace tag's blank space |
-`Font` is asynchronous at the renderer boundary. `Config` therefore keeps
-requested name/path/size, pending state, and the effective face/point-or-pixel size
-as separate facts; a failed request never gets reported as the face on screen.
-Taglines use a distinct face size in both native GUI renderers. Execute
-`TaglineSize <percent>` to change it live, for example `TaglineSize 70`; the
-accepted range is 1 through 100 and the default comes from
-`gui_tagline_font_percent` in `src/config.zig` (82). The native renderer
-remeasures both the glyph and its visible tag band while retaining body-grid
-pane geometry. Graphical tag text uses the smaller face's measured monospace
-advance and fills the available pixel width, including workspace and column
-tags. Text selection and scrolling use that same capacity; drag grips retain
-their physical body-grid width and position. The 100% ceiling is
-deliberate: a tagline remains exactly one logical grid row, so a larger face or
-band would overlap its pane body or a neighbour instead of leaving the body
-grid stable. `Config` reports the active percentage. The browser applies the
-same compiled percentage to its DOM glyphs but has no runtime setter.
+`Tty9p` (`SPC n 9`) is described in [v9fs.md](v9fs.md); the
+`PARDES_V9FS_HELPER` variable points development builds at the helper.
-Both native GUIs separate the reduced-height global and pane tagline bands with
-a `gui_topbar_pane_border_px` physical-pixel rule. Set it to zero to leave it out.
-`gui_topbar_pane_border_rgb` can pin an RGB color; its default `null` follows
-the theme's `border` role in SDL (falling back to `scroll_track` for older
-themes), and `scroll_track` in the macOS shell. Every band is centred in its
-row, so the workspace, column and pane tag text share one baseline offset and
-the anchors get the same margin above, below and to the left. With `Tagbottom`
-enabled, a tagline on the final grid row is bottom-aligned so the unused
-half-band does not show beneath it. The rule lives in the core
-(`pardes.taglineBandOffset`), and the macOS shell reaches it over the C ABI
-rather than keeping its own copy. When the window is not a whole number of cells,
-bands and rules at the right and bottom edges run on through the leftover pixels.
+### Terminals
-What happens to a codepoint the chosen face has no glyph for differs by shell.
-The SDL GUI falls back through a chain it builds itself: embedded Adwaita
-Mono, then installed `NotoSansMono-Regular`, `DejaVuSansMono`,
-`SymbolsNerdFont-Regular`, `NotoSansSymbols2-Regular`,
-`NotoSansSymbols-Regular`, and `DejaVuSans`, in that order, missing entries
-skipped; the rasterized glyphs are retained in its GPU atlas. The macOS shell
-has none of that and needs none. It embeds no font. Its default face is
-`NSFont.monospacedSystemFont`, resolved through the descriptor rather than by
-name, and `Menlo` only if that face turns out not to be fixed-pitch — a
-proportional face in a fixed grid is a broken screen, not a cosmetic problem
-(`PardesView.defaultFace`). A `Font <name>` arrives as a PATH the core already
-resolved by walking the font directories, and a file CoreText cannot measure
-leaves the face already on screen rather than substituting one, because the
-alternative is a terminal with no way back out (`PardesView.fromFile`).
-Per-codepoint fallback is CoreText's own cascade at draw time. What the shell
-caches is `CGGlyph` ids, not pixels.
+Ctrl-B switches a terminal between raw input and editor mode. Plain Escape
+at a detected shell prompt hops back to the previous pane; other keys,
+Ctrl-O, Ctrl-W and modified Escape included, go to the program. `Mode` in the
+tag returns to editor mode in place. Ctrl-V types the yank register and
+Ctrl-Shift-V the desktop clipboard, through bracketed paste when the program
+asked for it; neither reaches the program as a keystroke.
-Blank, unknown, malformed, or unsuccessful lines are ignored silently, and a
-bad line does not prevent later lines from running. Top-level text that is not
-a builtin is not sent to a shell. (`Exec ...` remains an ordinary builtin and
-therefore keeps its normal behavior.) Key bindings remain compile-time choices
-in `src/config.zig`; this startup file does not remap them.
+`Filter` in a terminal's tag maps its ANSI colours through the theme,
+keeping each foreground at least `tty_filter_min_contrast` (WCAG 1.5, in
+`src/config.zig`) against its background.
-## Panel and scene effects
+### Location results
-Exactly one panel transition is selected at a time. Executing its builtin a
-second time turns it off; selecting another replaces it:
+`LocationsConfig` with no argument prints the current settings as a line
+that can be run again; with fields it changes only those:
```text
-PanelSlide
-PanelZoom
-PanelDissolve
-PanelAscii
-PanelVertical
-PanelEdges
-PanelFall
-PanelWave
-PanelCurtain
-PanelScramble
-PanelType
+LocationsConfig context:5 tscontext:on tslocations:off layout:stacked
```
-All panel transitions start off. To disable one, execute its builtin again:
-`PanelDissolve` turns off an active dissolve, and `PanelAscii` turns off an
-active ASCII transition. `Config` shows the active command under
-`Panel transition`. Remove that command from your startup configuration to
-keep it off after restarting. Executing a different transition enables that
-one instead; these commands are toggles, not an unconditional animation-off command.
+- `context` (0): source lines shown above and below each match.
+- `tscontext` (off): include the enclosing tree-sitter declaration headers.
+- `tslocations` (on): show a location on each declaration header.
+- `layout` (`stacked`): the location on its own line; `inline` puts it
+ beside the source, padded in groups of eight matches.
-Slide uses cubic ease-out and zoom uses an
-overshooting ease-out-back. Dissolve and ASCII compare the last successfully
-presented grid with the new one. Dissolve switches visually changed cells at
-stable noise thresholds.
-ASCII walks every changed single-byte printable glyph
-from its old `u8` value to its new one, spending that byte distance as the
-frames of the walk. The walk is eased in and out: a glyph creeps at both ends
-and crosses the middle of its distance in a few large skips, inside the same
-number of frames a constant one-value-per-frame walk would have taken.
-Glyph-stable style changes
-and non-ASCII graphemes become canonical immediately. A walk is capped at
-twelve movement frames, and each pane lasts only as long as its longest walk.
-The core computes and composes that semantic diff once for every backend;
-pixel attachments, which have no character value, pass through unchanged.
+An invalid field rejects the whole line; a repeated field's last value wins.
+The settings survive Dump and Restore. Source analysis is cached for 64
+files and 64 MiB.
-Six further transitions are character *motion* over the same frozen/new grid
-pair, and are composed in the core the same way:
+### Effects
-- `PanelEdges` — whole rows slide in from alternating screen edges.
-- `PanelFall` — columns rain down into place, each with its own head start.
-- `PanelWave` — a vertical ripple travels across the pane and decays.
-- `PanelCurtain` — a wipe, left to right, behind a soft edge two cells wide.
-- `PanelScramble` — every cell churns through printable ASCII and locks onto
- its final glyph at its own stable noise threshold.
-- `PanelType` — reading-order reveal with a caret sitting on the write head.
+Panel transitions, one at a time; running the active one again turns it
+off: `PanelSlide`, `PanelZoom`, `PanelDissolve`, `PanelAscii`,
+`PanelVertical`, `PanelEdges`, `PanelFall`, `PanelWave`, `PanelCurtain`,
+`PanelScramble`, `PanelType`. All start off; the web shell has none.
-Unlike dissolve and ASCII these carry *every* glyph in the pane, changed or
-not: text flying in from a screen edge has to bring its unchanged glyphs with
-it. A cell whose glyph has not arrived shows the frozen old cell rather than a
-blank or a blend, so every intermediate frame is made of real characters. No
-cell is a valid input target until its own glyph has settled.
+Scene passes (SDL GUI, and a GUI attached to a detached session), each at a
+level 0-3 (`on` is 2), all off by default: `Crt`, `Bloom`, `Vignette`,
+`Grain`. `Shader <file.glsl>` adds a Shadertoy file written for ghostty to
+the chain (`Shader off` removes it; it recompiles when saved);
+`ShaderAnimation off|on|always` says when the chain animates by itself.
-Vertical is a pane-lifecycle effect: a newly added
-pane rises from below inside its own fixed box, and a deleted pane's frozen
-content drops back down; surviving panes are never animated. The TTY
-implementation performs its remaining geometry/dissolve operations directly
-on a copy of the core-composed presentation grid.
-The SDL and native macOS GUI implementations pass plain panel tracks to their
-GPU shaders, including native image/PDF pixels; layout itself commits
-immediately and remains the one authoritative geometry. DOM web intentionally
-does not expose these builtins: its renderer is selectable HTML/CSS and has no
-canvas or shader stage.
+The focused pane can stand off the page: `Lift shadow|rim|auto|off`,
+`InactiveDim <percent>`, `Motion off|crisp|smooth|bouncy|playful`
+(default `smooth`), `SelectionGlow`, `HoverGlow`, `Occlusion`, `Parallax`,
+`JumpTrail`, `ChipShadow`, `ThumbFlash`, `CursorBlink`, `GripWidth <50-300>`.
+Most are GUI-only; `InactiveDim` works everywhere, and `JumpTrail`,
+`ChipShadow` and `ThumbFlash` are the terminal's. No effect may lower the
+contrast of text, the selection or a focus indicator
+([effects.md](effects.md)).
-The scene effect is `Crt`, at a level from 0 (off) to 3; `on` is 2:
+`EffectCode <effect>` lists that effect's sources under `/virtual` when the
+build embeds them (`-Dembed-sources=true`). `zig build shaders` refreshes
+the committed SPIR-V with its GLSL.
-```text
-Crt
-Crt 3
-```
+### Look preview
-The SDL GUI runs it as the bundled pass of its post chain, which also takes
-Shadertoy files written for ghostty (`Shader ~/crt.glsl`, `Shader off`;
-a file compiles again when it is saved, and a save that fails to compile
-keeps the last good one and says why), and
-`ShaderAnimation off|on|always` says when the chain animates on its own. A
-GUI attached to a detached session runs the session's chain the same way. With
-the chain empty the pass is bypassed. CRT works in linear light with restrained
-scanlines, mask, bloom and vignette, and no curvature, so clicks land where
-they are drawn. Its slow hum and dither move on their own, so with Crt on the
-GUI keeps drawing while idle (under `ShaderAnimation on`, while focused);
-the other bundled passes are still and let it rest.
+Resting the pointer on text for about 32 ms (`look_preview_delay_frames`, 2
+frames) tints what a right click would look at, with no other effect. Set
+`look_preview_delay_frames` to `null` in `src/config.zig` to turn it off.
-Three more bundled passes take the same levels, each off by default and
-each costing nothing while off:
+## Themes from files
-```text
-Bloom 2 the brightest ink glows a little (only what is brighter than
- the page; a dual Kawase blur at half size and down)
-Vignette 2 the window's corners fall a little into shade
-Grain 2 the page's own ground takes a fine, still grain, like paper
-```
+`ThemeFile themes/mine.zon` loads a complete theme; a malformed save keeps
+the last good one, and `Theme <name>` stops the watch. `DumpThemes` writes
+every compiled theme to `<config dir>/themes/builtin/<name>.zon`: copy one,
+change its `.name`, and edit. The format is `pardes.Theme` as
+`std.zon.stringify` writes it; [themes.md](themes.md) describes each role.
-None of them touches a tag, a grip, a notice or the cursor, and each is
-capped per theme so text and the selection keep their own contrast or 4.5
-(docs/effects.md).
-
-The focused pane can stand off the page (SDL GUI, off by default):
-
-```text
-Lift shadow a soft drop shadow, on the other panes' bodies only
-Lift rim a hairline just above the focused tag (light, or shade on a light page)
-Lift auto a shadow on a light page; on a dark one the others recede
-Lift off
-InactiveDim 30 the unfocused panes' text fades 30% toward its ground
-Motion smooth off, crisp, smooth (the default), bouncy or playful
-SelectionGlow on a soft halo of the selection's colour round it in the body,
- fading in over 100 ms, never over a tag, a grip or the cursor
-HoverGlow on a soft glow under the word a look-hover would open, fading
- in over 80 ms
-Occlusion on pane bodies darken faintly toward their edges (2%), never
- over the cursor
-Parallax on a theme's page pattern (lapis's dots) moves with the text
- at a quarter of its speed
-JumpTrail on in a terminal, a jump of the cursor of three cells or more
- leaves a trail of a few cells that fades in 120 ms
- (truecolor terminals; a pixel shell glides instead)
-ChipShadow on in a terminal, a notice chip casts a shadow a cell right and
- down: half blocks on blank cells, a darker ground on text
-ThumbFlash on in a terminal, a pane's scroll thumb brightens as it scrolls
- and fades back over 250 ms
-CursorBlink on the cursor blinks, solid while typing and half a second
- after, eased at each edge, solid after 10 idle seconds
-GripWidth 150 the grip's button and the scrollbar under it, percent of the
- theme's rail_px (12px at a 17px tagline; 50 to 300)
-```
+## Dumps
-`InactiveDim` works everywhere (a grid shows it at once) and never takes a
-pair below its own contrast or 4.5. Under `Lift auto` on a dark page it is 30
-while unset. `Motion` sets how every such effect moves: see docs/effects.md.
+`Dump` writes `pardes-<date>-<time>.zon` (UTC) in `DumpDir`, creating the
+last directory if it is missing (a missing parent fails `no such
+directory`); `$PARDES_DUMP` overrides the file. `Restore <path>` looks for a
+relative path in `DumpDir`, then in the directory pardes started in; bare
+`Restore` takes the last dump this session wrote. `pardes -l <dump.zon>`
+starts from one.
-`EffectCode PanelAscii` or `EffectCode Crt` lists the current backend's
-build-embedded source paths under `/virtual`. Look opens each full file;
-no checkout is needed, but the build must carry them (`-Dembed-sources=true`,
-off by default); otherwise the command reports them unavailable. TTY exposes grid transitions, native GUI builds also
-expose scene shaders, and web has neither. Shared implementations share paths.
-SDL reports whether GLSL was compiled during this build or came from the
-`-Dprebuilt-shaders` snapshot paired with the committed SPIR-V.
-`zig build shaders` refreshes both files of every pair together,
-so editing live GLSL without that explicit refresh changes neither half of a
-prebuilt executable.
+A dump keeps panes, columns and their tags, each text pane's selection, the
+theme, and the settings that differ from a fresh session's; the font stays
+the frontend's. A terminal comes back with its last MiB of output as
+history, a `── restored history ──` line, and a new shell in its old
+directory; a command pane comes back finished (`exit ?` if it was running).
+Undo history and REPL bindings are not kept. A dump holds at most 6 columns.
-## Delayed Look preview
-
-The preview is enabled by default. Moving the pointer onto selectable text and
-leaving it still for `look_preview_delay_frames` — 2 animation ticks, and
-`animation.frame_ms` is 16, so about 32 ms — paints a subtle theme-derived
-preview of the exact operand a right-click Look would receive.
-Repeated motion reports in the same semantic grid cell do not restart the
-delay. The preview uses the same side-effect-free word/path expansion as Look;
-it does not focus a pane, move a cursor, install a selection, activate a PDF
-page, or execute anything. Motion to another operand, pointer leave, input,
-pane teardown, and relevant content changes cancel it.
-
-Set this compile-time option in `src/config.zig` to disable the feature:
+## Crash records
-```zig
-pub const look_preview_delay_frames: ?u16 = null;
-```
+A panic appends two lines to `crashes` beside `init` (build, time, platform,
+pid; then the panic message) before printing to stderr. There is no stack
+trace in it: collecting one from a panic handler can hang the process. The
+trace stays on stderr.
-## Build-time configuration
+## Build options
-Runtime settings live in `src/config.zig`. Build options are listed below;
-`zig build --help` lists the options available for the selected platform,
-including the standard `-Dtarget` and `-Doptimize` options.
+`zig build --help` lists the options for the selected platform.
| option | values | default |
|---|---|---|
-| `-Dplatform` | `tty`, `gui`, `web`, `macos`, `esp32p4` | absent builds the tty cli and the SDL gui together |
+| `-Dplatform` | `tty`, `gui`, `web`, `macos`, `esp32p4` | absent: the tty and SDL shells together, installed into `~/.local` |
| `-Dstatic` | bool | `false` |
-| `-Dquic` | bool; 9P over QUIC using system OpenSSL 3.6+ | `false` |
-| `-Dmupdf` | bool | on for a native target, off for web and esp32p4 |
-| `-Djpx` | bool | `true` — JPEG 2000, and with it scanned PDFs |
+| `-Dquic` | bool; 9P over QUIC with system OpenSSL 3.6+ | `false` |
+| `-Dmupdf` | bool | on natively, off for web and esp32p4 |
+| `-Djpx` | bool; JPEG 2000, and with it scanned PDFs | `true` |
| `-Dtree-sitter` | `disabled`, `zig`, `minimal`, `full` | `full` natively, `zig` for web, `disabled` for esp32p4 |
-| `-Dtheme-animation` | bool | on everywhere except `-Dplatform=esp32p4` |
-| `-Dworkspace-tag` | bool; draw the workspace tag row — the macOS shell hands its commands to the native menu bar instead | on except on `-Dplatform=macos` |
-| `-Dprebuilt-shaders` | bool | on for a bare `zig build`, off when `-Dplatform` names a shell |
-| `-Dtracy` | path to a Tracy source checkout | off |
+| `-Dembed-sources` | bool; serve the sources under `/src` | `false` |
+| `-Dstamp-commit` | bool; the git commit in `--version` and crash records | on for release builds and the `~/.local` install |
+| `-Dtheme-animation` | bool | on except for esp32p4 |
+| `-Dworkspace-tag` | bool; draw the workspace tag row | on except for macOS, whose menu bar carries it |
+| `-Dprebuilt-shaders` | bool; embed the committed SPIR-V | on for a bare `zig build`, off with `-Dplatform` |
+| `-Dtracy` | path to a Tracy checkout | off |
| `-Dmacos-identity` | codesigning identity for `pardes.app` | `-` (ad-hoc) |
| `-Ddump` | a `dump.zon` to embed in the web shell | none |
-| `-Dtest-filter` | substring; run only tests whose name contains it | none |
-| `-Dtest-rebuild` | bool; force fresh Zig test compilation, retaining cached C dependencies | `false` |
-| `-Dhelix-harness` | native reference executable for live differential tests | `HX_HARNESS`, otherwise `hx-harness` on PATH |
-| `-Desp32p4-cols` | u16, the board's grid width in cells | `56` |
-| `-Desp32p4-rows` | u16, the board's grid height in cells | `14` |
-
-The local board build emits an object. Firmware clock, serial port and profiling
-options belong to the sibling `05-zig-p4` toolchain's build.
-
-Two build inputs reach the running binary as ordinary values rather than as
-behaviour. `build.zig` reads `.version` from `build.zig.zon` through an untyped
-`@import("build.zig.zon")` — one place to bump — and `gitCommit(b)` reads the
-revision at configure time. Both land in `pardes_config` and are re-exported as
-`pardes.version` and `pardes.commit`, and `pardes --version` prints
-`pardes <version> (<commit>)`, or `pardes <version>` alone when there is no
-commit: a tarball, a container with no `git`, or a checkout outside version
-control all yield null, and the flag has to work anyway. Neither is a question
-asked at runtime — a binary that shelled out to `git` would describe whatever
-tree it was standing in rather than the one it came from.
-
-`Restore a.dump` first looks for the relative path in the dump directory:
-`DumpDir <path>` when set (a leading `~/` is your home; bare `DumpDir` returns
-to the default), else `$XDG_DATA_HOME/pardes` or `~/.local/share/pardes`.
-`Config` reports the directory in effect as `DumpDir <path>`, so the line can
-be fed back as configuration. If it is absent, Restore
-uses the argument as a path as before. Absolute paths and argument-free Restore
-retain their existing behavior. `Dump` still honors `$PARDES_DUMP` and otherwise
-writes a timestamped file in the default directory.
-
-A dump keeps each text pane's dot (its selection, or the caret), as acme's
-keeps a window's q0 and q1, and a Restore puts it back where the kept view
-shows it. It keeps the settings that differ from a fresh session's (`Placement
-pardes`, `Verbose off`, a shader), as the root ctl reads them, and a Restore
-sets them again; the theme is kept with the layout, and the font stays the
-frontend's. REPL bindings are not kept.
+| `-Dtest-filter` | run only tests whose name contains it | none |
+| `-Dtest-rebuild` | bool; fresh Zig test compilation | `false` |
+| `-Dhelix-harness` | reference executable for live differential tests | `HX_HARNESS`, else `hx-harness` on PATH |
+| `-Desp32p4-cols`, `-Desp32p4-rows` | the board's grid | 56, 14 |
-A restored terminal comes back live: its recorded output (the last MiB of it)
-is replayed as history, a dim `── restored history ──` line marks where it
-ends, and a new shell starts below it in the directory the old one was in,
-the same shell it ran (a `Tty bash` pane comes back running bash). A
-view left scrolled back stays where it was. Only the shell is new; nothing the
-old one was running is restarted. A command pane comes back finished, with
-what it printed and its `exit N`, and is not run again (a command still
-running when dumped comes back as `exit ?`). The web shell, which has no ptys, still
-shows the history alone.
+The version comes from `build.zig.zon`'s `.version`.
diff --git a/docs/design.typ b/docs/design.typ
index ed2bbb94..ec9bd5c4 100644
--- a/docs/design.typ
+++ b/docs/design.typ
@@ -526,13 +526,13 @@ verbatim by right-click and the delayed hover preview; that policy stays beside
input because it also observes live pane selections and wrapped grid coordinates.
Each pane kind keeps its storage and operations together in its own file
-(`File.zig`, `Terminal.zig`, `Output.zig`, `Mini.zig`, `pdf_view.zig`, and the
+(`File.zig`, `terminal.zig`, `Output.zig`, `mini.zig`, `pdf_view.zig`, and the
image pane in `image.zig`), reached through `panes.zig` beside `Pane`.
`layout.zig` owns placement and presentation state. `pardes.zig` handles input
and cross-pane state directly, without a pane vtable; the rest of the editor
is split by thing as acme is: `Text.zig`, `edit.zig`, `normal.zig`, `tagline.zig`,
`look.zig`, `exec.zig`, `mouse.zig`, `Messages.zig`, `Pipe.zig`, `colors.zig`,
-`surface.zig`, `body_layer.zig`, `tag_layer.zig` and `dump.zig`. Integration fixtures live
+`surface.zig`, `body_layer.zig`, `Layer.zig` and `dump.zig`. Integration fixtures live
in `test/panes.zig`, `test/output.zig`, and `test/pdf.zig`.
= State
diff --git a/docs/detached.md b/docs/detached.md
index 46e93fd5..e010677c 100644
--- a/docs/detached.md
+++ b/docs/detached.md
@@ -1,7 +1,7 @@
# Detached sessions
-A detached session owns the editor core, pane shells, files, undo history and
-layout. TTY and SDL frontends can join and leave without ending the session.
+A detached session owns the editor core, pane shells, files, undo history
+and layout. TTY and SDL frontends join and leave without ending it.
```sh
pardes --detach=work &
@@ -9,61 +9,41 @@ pardes --attach=work
pardes-gui --attach=work
```
-Bare `--detach` names the session after its process ID. Bare `--attach`
-requires exactly one listening session. The detached process runs in the
-foreground unless the shell backgrounds it.
+Bare `--detach` names the session after its pid; bare `--attach` needs
+exactly one listening session. The detached process runs in the foreground
+unless the shell backgrounds it. Inside an editor, `Attach work` (`SPC s a`)
+switches this window to that session and `Detach` (`SPC s D`) closes only
+this frontend.
-Inside an editor, `Attach work` (`SPC s a`) switches the current window to
-that session. `Detach` (`SPC s D`) closes only that frontend. It does not
-turn a local editor into a detached session.
-
-A message stays until the next key or click dismisses it. With no frontend
-attached there is none to come, so a detached session drops each pane's
-messages once the newest has been up `MessageLinger` on the clock, checked
-by the session's own loop between the editor's steps; /screen then shows
-what is current.
+With no frontend attached, `/ctl`'s `size C R` sets the screen (160x50 until
+then; [fs.md](fs.md#the-root-ctl)), and messages clear after `MessageLinger`
+on the clock, since no key will come to dismiss them.
## Files and transport
The frontend socket is `pardes-detached-<name>.sock` under
-`$XDG_RUNTIME_DIR`, or `~/.local/state/pardes` when no runtime directory is
-set. A second session cannot replace a live listener with the same name.
-Shared Unix socket handling lives in `src/9p_io.zig`.
-
-The session also opens its default 9P socket. Optional TCP and QUIC listeners,
-runtime mounts and the control filesystem belong to the session, not its
-frontends. See [fs.md](fs.md).
+`$XDG_RUNTIME_DIR`, else `~/.local/state/pardes`, beside the session's 9P
+socket `pardes-9p-<name>.sock`. A second session cannot take a live name.
+TCP and QUIC listeners, mounts and the control filesystem belong to the
+session, not its frontends.
## Ownership
-One poll loop owns all core mutation, frontend connections, PTY I/O and file
-watch notifications. LSP and selection-pipe workers own request snapshots and
-post completions through a bounded mailbox. Each subprocess is reaped by its
-owner; PTY reaping cannot consume a language server or filter's exit status.
-
-Restore constructs a replacement core before changing the current one. It then
-joins old work, clears obsolete completions, replaces panes and watches, and
-sends a fresh frame to the existing frontends.
-
-Frontends provide input and presentation. Clipboard writes are broadcast;
-clipboard reads, browser opens and Detach go to the originating frontend,
-falling back to the primary attachment. Frontends never spawn pane shells,
-write session files or install file watches.
+One poll loop owns core mutation, frontend connections, PTY I/O and file
+watches; LSP and selection-pipe workers post completions through a bounded
+mailbox. Restore builds the replacement core before changing the current
+one, then sends existing frontends a fresh frame. Frontends provide input
+and presentation only: clipboard writes are broadcast; clipboard reads,
+browser opens and Detach go to the frontend that asked (else the first
+attached). Frontends never spawn shells, write session files or watch files.
## Wire and tests
-`src/detached/wire.zig` owns the versioned frontend protocol. Frames are full
-grids or changes relative to each frontend's last queued frame. A new
-attachment receives a full grid. Output queues and per-poll work are bounded;
-a lagging frontend cannot hold the session's event loop. Protocol version 6
-also carries the pointer shape, compact source-context body layers, source-gap
-separators and independently sized tag text. Pointer
-updates work even when no grid cells change, and graphical clients use the
-same compact row and tag geometry for drawing and mouse input. Tag text positions
-are separate from physical pane and drag-handle coordinates. Terminal clients retain
-the ordinary fixed grid.
+`src/detached/wire.zig` owns the versioned frontend protocol (version 8).
+Frames are full grids or changes against each frontend's last frame; a new
+attachment gets a full grid. Queues are bounded, so a lagging frontend
+cannot hold up the session.
-`zig build unit-test` covers encoding, session ownership, real frontend
-connections, worker completion and Restore. `zig build fs-test` drives
-detached sessions through an independent 9P client. The snapshot suites also
-exercise attach, detach and shared screen behavior.
+`zig build unit-test` covers the wire, ownership, real frontend connections
+and Restore; `zig build fs-test` drives detached sessions over 9P; the
+snapshot suites exercise attach, detach and shared screens.
diff --git a/docs/divergences.md b/docs/divergences.md
index 6679d962..2ab65819 100644
--- a/docs/divergences.md
+++ b/docs/divergences.md
@@ -1,94 +1,17 @@
# Divergences
-What is not on `main`, and what on `main` is known to be wrong. Written so
-that moving a bookmark does not quietly orphan work or hide a failure.
+What is not on `main`, and what on `main` is known to be wrong, so that
+moving a bookmark does not quietly orphan work or hide a failure.
-## Two lines, forked at `01104e7c`
+## Bookmarks off `main`
-`main` is not the only living line, and the other one is not behind it —
-they are siblings:
+| bookmark | |
+|---|---|
+| `reload-perf-wip` | unfinished: Reload presentation transport regression |
+| `reload` | reload core code with shell-owned allocators |
+| `reload-start` | names the Core/Shell seam, deferred Reload request |
+| `tty-colors-mouse`, `vibes-ghostty`, `mouse` | older prototypes (divergent), also on the `vps` remote |
-```
-◆ rruwvuzm 09-17 editor work: syntax, panes, modal, gui, fs, output
-│ ◆ xqxpolmw 2b547e15 main 09-20 "Serve Unix and TCP 9P through cloud9.serve"
-├─╯
-◆ lsnxpxtq 01104e7c 09-16 "Add macOS backdrop blur and preserve PDF ink opacity"
-```
-
-* **`main`** carries the 9P work: the `cloud9.serve` runner, and now the
- posted-9P registry (`docs/cloud9.md`).
-* **`rruwvuzm`** carries editor work — roughly 1300 lines across
- `src/syntax.zig`, `src/panes.zig`, `src/modal.zig`, `src/gui/gui.zig`,
- `src/fs.zig`, `src/pardes.zig`, `test/output.zig`, `docs/fs.md`. Reach it
- with `jj edit rruwvuzm`.
-
-The fork matters for one concrete reason: **the cloud9 pin lives on `main`
-only**. `rruwvuzm` still pins `ae310a20` (2026-09-14), `main` now pins
-`9c4d668c`. Rebasing or merging the editor line will want the newer pin, or
-`zig build` there fetches a cloud9 that predates `fs.Server`'s current shape.
-
-## Other bookmarks
-
-| bookmark | | |
-|---|---|---|
-| `reload-perf-wip` | 09-11 | unfinished: Reload presentation transport regression |
-| `reload` | 09-10 | reload core code with shell-owned allocators |
-| `reload-start` | 09-10 | names the Core/Shell seam, deferred Reload request |
-| `ninep` | 08-27 | a 9P design note and a design registry to argue it in |
-| `macos-fix` | 07-23 | |
-| `full-prototype`, `term`, `tty-colors-mouse`, `vibes-ghostty`, `mouse` | 06-xx | older prototypes, also on the `vps` remote |
-
-None of these are published to the FreeBSD mirror: only `main` is pushed
-there, deliberately, because that box serves a public site.
-
-## Known-failing on `main`, not caused by the 9P work
-
-* **`zig build snap` fails `nested-optout`, and the cause is a real bug.**
- At the SECOND level of nesting -- a pardes whose shell runs a pardes whose
- shell runs a command -- two U+E016 codepoints are prepended to whatever is
- typed. U+E016 is 57366, which is vaxis's private-use spelling of **F3**
- (`Key.zig`, "kitty encodes these keys directly in the private use area"), so
- something in the chain is decoding a capability-query REPLY as a key and
- forwarding it to the shell as text. bash then sees
- `$'\356\200\226\356\200\226echo'` and answers `command not found`; with a
- path it answers `No such file or directory` for a path that exists and runs
- perfectly from the same shell a moment later.
-
- Minimal repro, in a snapshot script:
-
- ```
- dirmk innerdir
- file innerdir/deeper.txt deepest-marker\n
- start 31 100
- wait 8000 $
- stable 700 20000
- text (cd innerdir && $(readlink /proc/$PPID/exe) --nested)
- key enter
- wait 20000 cwd/innerdir
- stable 700 20000
- text E=$(readlink /proc/$PPID/exe); "$E" deeper.txt
- key enter
- settle 5000
- stable 700 10000
- snap probe
- ```
-
- `bash: E=/home/.../pardes: No such file or directory` -- bash did not even
- parse the assignment, because the line does not start where it looks like it
- starts. `--nested` is exactly the flag meant to opt a child out of this, so
- the fix belongs next to the key-forwarding path in `panes.Terminal.forwardKey`
- and whatever answers terminal capability queries on a nested stdin.
-
-* **pardes does not start headless.** `pardes --9p=<name>` with no terminal
- exits 1 from the argument-forwarding path; the installed build fails
- earlier still, with `error.NoDevice` opening a terminal device. So the
- registry posting above is covered by unit tests
- (`zig build 9p-io-test`) rather than by running the editor.
-
-## Upstream
-
-`build.zig.zon` pins cloud9 `9c4d668c`. That commit exists because pinning
-cloud9 `534c084f` here failed: cloud9's `build.zig` `@import`s each program's
-build fragment, and `9harness` was missing from its `.paths`, so the
-published package built from a checkout and not from a tarball. The pardes
-build was the first consumer to notice.
+`ninep`, `fx`, `macos-fix`, `full-prototype`, `term`, and the `before-*` and
+`merged-*` bookmarks mark points already in `main`'s history. Only
+`main` is pushed to the FreeBSD mirror, which serves a public site.
diff --git a/docs/fs.md b/docs/fs.md
index ca02082b..06fa1406 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -1,1142 +1,658 @@
# Filesystem
-Every native session serves 9P2000 on a Unix socket. Pane shells receive
-`PARDES_PID` (the editor's process id), `PARDES_9P` (socket path) and
-`PARDES_PANE` (pane serial). The socket is
-`$XDG_RUNTIME_DIR/pardes-9p-<pid>.sock`, or lives under
-`~/.local/state/pardes` when XDG_RUNTIME_DIR is unset. Detached sessions use
-their session name; `--9p=<name>` overrides it.
+Every native session serves 9P2000 (not .u, not .L) as a control
+filesystem, in acme's manner: panes, columns, tags and the session are files.
+This page is the reference for what each file does. The served
+[`/README`](../src/fs-help.txt) is its one-screen summary.
-A `pardes <file>` launched from a pane forwards Look to that pane over 9P.
-`PARDES_PID` alone says the shell is inside pardes; `PARDES_9P` and
-`PARDES_PANE` say how to reach it, and a launch that has the first without the
-other two refuses rather than opening a second editor. `--nested` opens a
-separate editor and withholds `PARDES_PID` from its pane shells, so a pardes
-started in one of them runs a session of its own; its 9P service stays
-available.
+## Connecting
-A forwarded launch returns at once, as acme's `B` does. `--wait` (`-w`)
-returns only once the pane the file landed in -- the one already showing
-it, if any -- is deleted (exit 0), or the session goes away (exit 1), as
-acme's `E` does: what an `$EDITOR` must do, since fish's Ctrl-O
-(`edit_command_buffer`), `git commit` and `crontab -e` read the file back
-when the editor exits. Set `EDITOR='pardes --wait'`; `GIT_EDITOR` follows
-`EDITOR` when unset. Outside pardes `--wait` changes nothing, a session
-blocking anyway.
+The socket is `$XDG_RUNTIME_DIR/pardes-9p-<name>.sock` (else under
+`~/.local/state/pardes`), `<name>` being the pid, the `--detach=NAME`, or
+`--9p=<name>`. A socket in the runtime directory is also posted in the 9P
+registry as `$XDG_RUNTIME_DIR/9p/pardes/<name>` (a symlink to the socket;
+[cloud9.md](cloud9.md#the-posted-9p-registry)).
-Look resolves the OS filesystem first, then the editor's virtual filesystem.
-Explicit paths bypass that search:
+Pane shells get `PARDES_PID` (the editor's pid), `PARDES_9P` (the socket)
+and `PARDES_PANE` (their pane's serial).
-| Editor path | Meaning | 9P server path |
-|---|---|---|
-| `/n/os/proc/self` | OS filesystem | `/os/proc/self` |
-| `/n/self/pane/2/body` | pane 2's text | `/pane/2/body` |
-| `/virtual/pane/2/body` | the same, in the editor's own spelling | `/pane/2/body` |
-| `/virtual/src/pardes.zig` | source embedded in this build | `/src/pardes.zig` |
-| `/n/peer/pane/2/body` | another session's text | peer's `/pane/2/body` |
+**Forwarding.** `pardes FILE` run in a pane (a live `PARDES_PID`, with
+`PARDES_9P` and `PARDES_PANE`) writes FILE to that pane's `look` and returns
+at once, as acme's `B` does. FILE must already exist; a name that does not
+resolve, or a missing `PARDES_9P`/`PARDES_PANE`, starts a separate editor
+instead. Bare `pardes` in a pane refuses and names `--nested`.
-The mount name `self` is reserved and maps to the server root, so `/n/self/X`
-and `/virtual/X` both name the served `/X`.
+`--wait` (`-w`) returns when the pane that shows FILE is deleted (exit 0) or
+the session goes away (exit 1), as acme's `E` does. Use
+`EDITOR='pardes --wait'` (`GIT_EDITOR` follows `EDITOR`), so fish's Ctrl-O,
+`git commit` and `crontab -e` read the file after you close its pane.
+`--nested` runs a separate session whose shells do not forward to it.
-`--mount=peer=work` mounts the named session `work`; the dial can also be an
-absolute socket path, `unix!/path`, `tcp!IP!port`, or `quic!IP!port`.
-At runtime, use `Mount peer dial` and
-`Unmount peer`. Mount dials the peer when it mounts it and fails its write
-if nothing answers, `Mount peer /tmp/s: dial failed: no answer` (or `timed
-out`, `hung up`), mounting nothing; a peer that goes away later is found out
-by the next use, as `look: /n/peer/f: dial failed: no answer`. There are eight named mounts; `os` and `self` are reserved.
-Unmount refuses mounts still used by a pane, its working directory, or a
-pending Save. Mounts are saved in dumps. Save uses the file's original mount.
+**Clients.**
-`pardes --9p-tcp='tcp!127.0.0.1!5640'` adds a TCP listener alongside the Unix
-socket. Build with `-Dquic=true` and system OpenSSL 3.6+ to enable QUIC;
-`--9p-quic='quic!127.0.0.1!5641'` adds its listener. Both accept numeric
-IPv4/IPv6 addresses, not DNS names. Listener port zero chooses a free port;
-`/listeners` reports all active dial addresses.
+```sh
+9p -a "unix!$PARDES_9P" read index # plan9port, no mount
+9ns --unix "$PARDES_9P" -- sh -c 'cat "$NINE_MOUNT/index"' # private mount
+9ns --mntgen # the whole registry at /mnt/9p
+```
-All connections have session access, including `os`. TCP is unencrypted.
-QUIC uses an ephemeral TLS identity without peer verification or login.
-It carries 9P2000 on one bidirectional stream with ALPN `pardes-9p`.
-Unix and TCP connections share sixteen slots served by cloud9's `std.Io`
-runner; QUIC has sixteen of its own on the editor's poll loop. A client
-that finds every Unix/TCP slot taken gets an Rerror `too many connections`
-to its Tversion, and the log an `err - 9p: too many connections` record. OpenSSL's
-internal buffers are separate, dynamically allocated memory.
+Under `9ns --unix` the session is `$NINE_MOUNT` itself and exists only inside
+that command. Under `9ns --mntgen` every posted session is
+`$NINE_MOUNT/pardes/<pid or NAME>/`; take the name from `$PARDES_9P`. A dead
+session's entry stays listed and answers `Input/output error`, so name the
+session rather than globbing. `Tty9p` gives one pane's shell a kernel mount
+at `$PARDES_MOUNT` ([v9fs.md](v9fs.md)).
-[Plan9port's client](https://9fans.github.io/plan9port/man/man1/9p.html) can
-drive Unix or TCP without a kernel mount, and `9ns` mounts the tree in a
-private namespace:
+plan9port's `9p write` always opens with OTRUNC, so `echo x | 9p write
+pane/3/body` replaces the whole body where acme would append. Append with
+`>>` through a mount.
-```sh
-9p -n -a "unix!$PARDES_9P" read index
-9p -n -a 'tcp!127.0.0.1!5640' ls pane/1
-9ns --unix "$PARDES_9P" -- sh -c 'cat "$NINE_MOUNT/index"'
-```
+For [Linux v9fs](https://www.kernel.org/doc/html/latest/filesystems/9p.html)
+use `version=9p2000,cache=none,access=any`, `trans=unix` (or `trans=tcp`
+with `port=`), `uname`, `dfltuid` and `dfltgid` for the local user, and an
+empty `aname`.
+
+**Listeners.** `--9p-tcp='tcp!127.0.0.1!5640'` adds TCP;
+`--9p-quic='quic!127.0.0.1!5641'` adds QUIC (build with `-Dquic=true`,
+OpenSSL 3.6+; ALPN `pardes-9p`, an ephemeral TLS identity, no peer
+verification). Addresses are numeric IPv4/IPv6; port 0 picks one; `/listeners`
+reads them back. Every connection has full session access, `/os` included,
+and TCP is unencrypted: use loopback. Unix and TCP share 16 connection slots;
+a 17th client's Tversion gets `too many connections` (and the log
+`err - 9p: too many connections (N turned away)`). QUIC has 16 of its own.
+Plan9port and v9fs need a userspace bridge for QUIC.
+
+**Look paths and mounts.** Look resolves the OS filesystem first, then the
+editor's own tree. Explicit paths skip that search:
-(Under `9ns --unix`, `$NINE_MOUNT` is that session's root itself.)
+| Look path | Meaning | Served path |
+|---|---|---|
+| `/n/os/proc/self` | the host filesystem | `/os/proc/self` |
+| `/n/self/pane/2/body`, `/virtual/pane/2/body` | this session's tree | `/pane/2/body` |
+| `/virtual/src/pardes.zig` | sources embedded with `-Dembed-sources=true` | `/src/pardes.zig` |
+| `/n/peer/pane/2/body` | a mounted session | the peer's `/pane/2/body` |
-A `9ns --unix` mount lives in the private namespace of the command it runs,
-and nothing outside that command sees it. The mount everyone on the machine
-shares is the registry one, `9ns --mntgen` (default `/mnt/9p`): every running
-editor posts itself there, so `$NINE_MOUNT/pardes/<pid>/` is that editor's tree
-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, where `<dir>` is the session's
-directory (the one pardes started in), whichever pane last had the keyboard:
-no pane asked for it, as acme's new window has acme's directory. So is a
-`New` written to a column's `exec` or to `/tagexec`, a word in a tag no
-pane owns. 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).
+`--mount=peer=work` or `Mount peer <dial>` mounts a session name, an absolute
+socket path, `unix!/path`, `tcp!IP!port` or `quic!IP!port`; `Unmount peer`
+removes it. Mount dials at once and fails if nothing answers (`dial failed:
+no answer`, `timed out`, `hung up`). There are eight named mounts; `os` and
+`self` are reserved. Unmount refuses a mount a pane, a working directory or
+a pending Save still uses. Mounts are dumped.
-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`
-with `port=5640`. Set `uname`, `dfltuid`, and `dfltgid` for the local user.
-Leave `aname` empty. The opt-in
-[Linux v9fs experiment](v9fs.md) tests a kernel mount in a separate subprocess
-namespace (`zig build v9fs-test`, requiring explicit mount authorization).
-Neither 9P2000.u nor 9P2000.L is implemented.
-Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
+A session may open its own tree through a mount (a Look at
+`$m/pane/2/body` from the editor serving `$m`): requests are answered on the
+connection's task while the editor waits in its syscall. Through QUIC that
+still hangs.
-## The served tree
+## The tree
```
-/README this guide, also src/fs-help.txt
-/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name, column serial
- (the name as the log shows it: one line of UTF-8, a newline in it `\n`,
- a backslash `\\`, a byte not UTF-8 `\xNN`)
-/status pid, version and pane count
-/look write a line: a right click on it at the active pane; read: the serials the last
- look, exec or ctl write touched (made, else acted at)
-/exec write a line: a middle click; read the same serials
-/log recent events, one a line: new|del|rename|save <serial> <name>, msg <serial|-> <text>,
- dump|restore <path>, err <serial|-> <file>: <why>;
- write follow to that open to wait for more
-/screen rendered screen JSON; frozen per open handle
-/listeners the session's dial addresses
-/focus the serial of the pane with the keyboard; write a serial to give it the keyboard,
- which makes its column the active one as a click there would
-/ctl the settings, one a line as a write takes them; write a setting or a session builtin;
- `size <cols> <rows>` sets the screen of a session no frontend is attached to
- (`--detach`, 160x50 until then; refused while a frontend owns the size),
- from 20x6 to 4096x4096 (outside that, `invalid size`, EINVAL), and refused
- when a column has not the rows for its panes' minima, each its tag and 2
- rows, the minimum placement keeps; so a size once taken is taken again,
- and growing is never refused. A pane the resize took under its minimum
- gets its rows back from its column's others; a terminal's pty follows its
- pane on every resize
-/commands every builtin: word, `arg` if it takes one, `root`, `pane` or `both` (the ctl that takes it:
- Edit is both, at the active pane from the root; a pane's word such as Undo or Msg
- is refused at the root), a setting's values, then ` -- ` and what it does
-/recent the files opened lately, closed ones too, most recent first, a line each:
- `open <path>` or `closed <path>` (read-only; `Recent` shows them in a pane,
- a look at a row reopening the file at its last dot; kept across sessions
- in $XDG_STATE_HOME/pardes/recent, 200 files, the oldest closed one dropped
- first, never an open one; only files on disk, not a name never saved nor
- /virtual/; a name escaped as /index's is; acme has none, its dump and
- Load the nearest)
-/layout one line per column (16 at most; the board 6; Newcol past that fails,
- `no space for a column: 16 max`, ENOSPC; and each at least 10 cells
- wide, so Newcol from a column under 20 fails `this one is too
- narrow to split`, ENOSPC; the root's Newcol halves the active column,
- so reaching 16 means running Newcol from the widest column's tag),
- left to right: serial index x width current|notcurrent
- (the column with the keyboard now) empty|full pane-serials...; then active
- <serial>: acme's activecol, which the keyboard leaving for another column's
- tag does not move, so the two can differ -- the active column, where
- pane/new and a look place a pane next (- when there is none)
-/tag the workspace tag; > replaces it, >> appends, one line; a control character
- but a tab (a NUL too), DEL, a C1 control or bytes not UTF-8 are refused, `invalid
- tag text: ...` (EINVAL), in any tag, a pane's or a column's too
-/tagexec write a word: a middle click on it in the workspace tag; read as /exec. A
- pane's word (Undo, Msg, Save, Edit too) is refused there and at a column's exec,
- `not a session control message "Undo": write it to pane/<n>/ctl` (EINVAL),
- never done at the pane with the keyboard; a tag's own words (New, Tty,
- Find, Grep in a column's) run
-/col/<n>/tag the tag of the column with serial n, the same way
-/col/<n>/ctl write Delcol, Joincol, New or Tty: each acts on that column, as from its tag
-/col/<n>/exec write a word: a middle click on it in that column's tag; read as /exec; rmdir col/<n> closes
- an empty column (a column with panes is refused, ENOTEMPTY)
-/pane/new open it to make a pane (the bottom half of the active column's last
- pane, acme's coladd; with no room there, last all the same, taking half
- the tallest pane's rows); the read answers that pane's serial. A session holds
- 64 panes (16 on the board); at that, every route that would open one -- this
- open, look, exec, New, Tty -- fails with `no space for a pane: 64 max` (ENOSPC through 9ns, which has
- no word for ENFILE) and an err
- record, and look reads back empty; a column with no room for one
- (each pane keeps its tag and 2 rows) refuses it the same way,
- `no space for a pane in that column` (docs/tags.md)
-/pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll
- errors event look exec tagexec (a word as a click in its tag), plus
- pty/{ctl,status,data} on terminals
-/os/ the host filesystem
-/src/ the editor's embedded sources, only when built with -Dembed-sources=true
+/README the one-screen guide (src/fs-help.txt)
+/index a line per pane: serial kind dirty name column
+/status pid, version, panes
+/look /exec write a line: a right / middle click at the active pane; read: the serials touched
+/log the event log; write `follow` to wait for more
+/screen the rendered screen as JSON
+/listeners dial addresses
+/focus the serial of the pane with the keyboard; write one to move it
+/ctl settings and session builtins
+/commands every builtin, one a line
+/recent files opened lately: open|closed <path>
+/layout a line per column, then `active <serial>`
+/tag /tagexec the workspace tag, and a word clicked in it
+/col/<n>/ tag ctl exec of column <n>; rmdir closes an empty one
+/pane/new open it to make a pane; read answers the serial
+/pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll
+ errors event look exec tagexec, and pty/{ctl,status,data,run} on terminals;
+ rmdir closes the pane
+/os/ the host filesystem
+/src/ the editor's sources (only with -Dembed-sources=true)
```
-`/layout`, `/tag` and `/col` go past acme, which serves no column files --
-its columns are only where a window sits. They are here so a script can see
-where the panes are (`/index`'s last word is each one's column serial) and
-edit the tags a person clicks in: a column tag is one line, a newline
-written into it a space, and a truncating write (`echo Make > col/3/tag`)
-clears it and drops the newline that ends it, as a pane tag's does. A
-column is named by its serial, as a pane is: it stays while the column
-lives, whatever opens or closes beside it, and is never reused; /layout
-gives each column's serial and its index left to right, and the log says
-`newcol <serial>` and `delcol <serial>` as columns come and go, and after a
-Restore `restoredcol <old> <new>` for each column as `restored` does for
-panes. Joincol folds a column into the one on its right, which keeps its own
-serial and tag; the joined column's panes go below that column's own, in
-their order, and its serial is gone (`delcol`). The root ctl
-takes no column word: its `Delcol` is refused, pointing at
-`col/<serial>/ctl`.
+Panes and columns are named by serials the editor gives: stable while they
+live, never reused. Nothing is created by a listing, stat, walk or read:
+only an open of `/pane/new` makes a pane (so `ls -l` and `find` are safe),
+only `rmdir` of `/pane/<n>` or an empty `/col/<n>` removes. Tcreate is
+refused everywhere.
-Control messages are split by what they act on, as acme keeps window verbs
-on a window's ctl and webfs and upas/fs keep session settings on a root ctl.
-Each builtin declares its scope in src/builtins.zig (`scope = .session`;
-every setting is one, the rest act on a pane). `/ctl` takes the session's
-builtins, one a line, at whichever pane has the keyboard as each runs --
-`Newcol`, `Dump`, `Mount name dial`, `Theme ink`, `Verbose off`; `Exit`,
-which quits the editor as acme's does (it refuses once over the panes with
-unsaved text, a `+New` scratch of 100 bytes or more too, whether it came
-through a `ctl` write or a click: first one `/log` record per pane,
-`unsaved <serial> <name>`, then the write fails with one line that is
-never a list cut short, `<name>: Modified (Exit again to discard)` for one
-pane, `4 unsaved panes: Modified (Exit again to discard)` for more, and
-the `err` record says the same. On screen the notice is short, `3 unsaved
-panes — Exit again to discard`, and the whole list stays in one `+Unsaved`
-pane, filled again by each refusal: a row a pane, `<name>: Modified` (a
-`+New` scratch with its serial, as scratches share a name, `/dir/+New
-(pane 12): Modified`), then `Exit again to discard`. Restore,
-Del and Delcol refuse the same way, with their own word; an `Exit` after more editing refuses
-again naming only the panes edited since the last refusal, as acme's does,
-and an `Exit` with nothing edited since quits, throwing all of it away; a scratch or a
-command's output under 100 bytes is not asked about, as acme's winclean
-asks about no small unnamed window (so a scratch's `dirty` of 1 in `/index`
-blocks nothing until it holds 100 bytes: it has no file to be out of step
-with, and a few lines typed to try something are not work to lose); `Restore`, which replaces every pane,
-asks the same first -- `Dump` writes `pardes-<date>-<time>.zon` (UTC) in
-`DumpDir` (default `$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`)
-and logs `dump <path>`, and `Restore` with no path takes the last one; a
-Restore puts a new editor under every client, so the write of it is
-answered and then every connection is hung up, their fids naming the old
-editor's panes (a 9ns older than cloud9 2a7137c could fail the write with
-ECONNRESET all the same, when another request's send met the hang-up
-before the answer was read; the log is the authority): dial again -- a 9ns
-mount is one such connection, so after a Restore stop it and start 9ns
-again, or every file under it fails -- and the new
-log names the restored panes and
-`restore <path>`. Undo history does not survive a Restore: a restored pane
-starts with none. Restored panes have new serials (`restored <old>
-<new>` maps them) and so may columns (`restoredcol`; a fresh editor counts
-column serials from 1 again, so they often come back the same). A command
-pane comes back showing what it showed, its tag saying how it ended,
-`exit 0` (`exit ?` for one still running when dumped, whose end nobody
-saw): its command is not run again. REPL bindings are not dumped. The answer has 200 ms to leave before the cut, so a slow
-client may see only the cut; the log's `restore <path>` is what says the
-Restore happened. Keeping connections across it would mean carrying serials
-and opens into the new editor, which acme, whose Load only adds windows,
-never needed); and `Kill`, which
-does not quit but stops commands, as acme's does: bare, every command pardes
-started, and `Kill make ls`, those whose line begins with one of the words. A
-command pardes started is a command pane's, until its child exits -- Kill
-sends SIGTERM to its running job, to its shell and to every `&` job the
-line started, so the whole command stops and leaves nothing running (of
-`sleep 30; echo done` the `echo` never runs; an `&` job outlives only a
-command that exits on its own) -- or a line it typed into
-a terminal (a
-word written to `exec`, a middle click on one, a `pty/run`), from its shell's
-start mark (C) to its end mark (D), where Kill sends its foreground job SIGTERM -- acme posts the
-"kill" note, which ends a process -- and never signals the shell itself;
-in a shell running without job control (`set +m`) the job shares the
-shell's group, so there is none to signal: Kill says `Kill: no job to
-signal`, and a write of it to `ctl` fails with that; with nothing running
-it says `Kill: nothing running`; and, of a typed line, only the
-foreground job, after which what the rest of the line does is the shell's
-affair: of `sleep 30; echo done` typed at a prompt, bash (saying
-`Terminated`) and fish (saying `Job 1, 'sleep 30' terminated by signal
-SIGTERM`) both go on and run the `echo`: SIGTERM, unlike an interrupt,
-ends only the job, not the line --
-and reads every setting there is, one a line, in the words a write of it
-takes (`Verbose on`, `WindowOpacity 70`, `PanelSlide off`, `DumpDir
-<the directory in effect>`, `LocationsConfig ...`), so writing what it reads
-back changes nothing; a setting the frontend cannot show (`Lift`,
-`GripWidth` on a terminal) is refused as `Lift is GUI-only, invalid here`
-(EINVAL: a request this build cannot take); acme's own words run as
-pardes's where it has one -- `Put` is `Save`, `Delete` a `Del` that does
-not ask -- and the rest (`Get`, `Putall`, `Snarf`, `Cut`, `Paste`, `Zerox`,
-`Sort`, `Load`, `ID`, `Send`) are refused, EINVAL, `invalid: acme's Snarf
-is not a pardes builtin`, never run as shell commands; and so is a builtin only the
-GUI has (`Fonts`), written to an `exec` too, where it would otherwise run as
-a shell command; a `Shell` or `Tty` naming no
-shell says `shell "x" not found` (ENOENT); a `DumpDir` whose last
-directory is missing has it made at the Dump, and one further up missing
-says `Dump <path>: no such directory` (ENOENT), one that is no directory
-(`/dev/null`) `Dump /dev/null/pardes-<time>.zon: /dev/null is not a
-directory`, each naming the dump file it would have written; platform and startup facts are `/status`'s and the
-Config window's, not settings. A pane's `ctl` takes the builtins that act on
-a pane (`Del`, or `Del k`/`Del j` to give its rows to the pane above or
-below, `Save f`, `Collapse`, which folds that pane, `Undo` and
-`Redo`, which step its body through its last 256 edits -- with none left
-they say `Undo: nothing to undo` and the write still succeeds, as acme's
-Undo is silent --, `Find pat`)
-beside acme's `get`, `lock` and `unlock`; acme's other ctl words are taken
-too, done by the file or builtin that replaces each: `name x` (the `name`
-file), `put` (`Save`), `clean` and `dirty` (`dirty`), `del` (`Del`),
-`delete` (a `Del` that does not ask), `dot=addr`, `addr=dot`,
-`limit=addr`, `mark`, `nomark` (`mark`), `show` and `cleartag`; `dump`,
-`dumpdir`, `font`, `menu` and `nomenu` are refused with why, EINVAL. The column words are pane words
-too, acting on the column that pane is in: `Delcol`, `DelAbove`, `DelBelow`
-and the focus moves `Left`/`Right`/`Up`/`Down` from it. `Joincol` and
-`Newcol` are the root's: they act on the column of the pane with the
-keyboard, and `Joincol` needs a column to its right (`Joincol: no column to
-the right`). The words are case-sensitive and do not alias: acme's verbs
-are lowercase and the builtins keep their tag spelling, so `Get` is no word
-and `del` none either.
+`/index` rows are `serial kind dirty name column`, kind `text`, `term`,
+`pdf` or `image`, the name `<dir>/+New` for an unnamed scratch. Names may
+hold blanks, so split `head, col = row.rsplit(maxsplit=1)`, then
+`serial, kind, dirty, name = head.split(maxsplit=3)`. Names in `/index`,
+the log and a terminal's tag are escaped: a newline `\n`, a backslash `\\`, a byte that
+is not UTF-8 `\xNN`.
-A write is checked whole before any line of it runs, and a line is refused
-in Plan 9's words for a ctl (kernel/misc/parse.c:82-97), quoting the line:
-`unknown control message "X"`; `wrong #args in control message "X"` for an
-argument to a builtin that takes none, or none to one that needs it (`Msg`,
-`Mount`, `Find`, a setting's value but a switch's, which flips bare);
-`bad value in control message "X"` for a setting's value it does not take
-(for `Theme`, whose names are any case, naming every theme that shares
-the name's first letter when they fit the 128 bytes a 9P error carries,
-else the nearest few, since all of them, `Themes`'s list, are too many);
-and `not a session control message "X": write it to pane/<n>/ctl` or `not
-a window control message "X": write it to /ctl` for a word of the other ctl. 9ns maps them all to EINVAL, and a write
-refused here has done nothing. A line that then fails as it runs fails the
-write with the error the editor reports for it, in its own words, which
-name what failed, the line not quoted after them (only a line refused
-before it runs, as no message at all, is quoted), e.g. `Mount: already
-mounted` (EIO), and `control message needs its
-argument "Save"` (EINVAL), for a builtin that would have asked at a prompt
-(a `Save` on a scratch) rather than open one nobody is there to answer. A
-builtin that means nothing without its argument (`Mount`, `Msg`, `Find`),
-written bare to an `exec` or `/tagexec`, is refused the same way, `wrong
-#args in control message "Mount"` (EINVAL), before it runs. The
-lines before a failing one have taken effect and those after it never run,
-which is what acme's ctl loop does (editors/acme/xfid.c:600-790). An error
-that only happens as the editor performs what a line asked for -- a `Save`
-whose disk write fails -- fails the write too, once the editor has tried
-(`Save /root/x.txt: access denied`, which 9ns reads as EACCES, a shell's
-`Permission denied`), and changes nothing: a scratch
-keeps its name and stays a scratch, a clean file stays clean, a dirty one
-dirty. Like any write, a ctl write answers once the editor has
-performed what it asked for (a save written, a shell started). A click on
-the same word, or the word written to `exec`, still opens its prompt.
+## Rules for every file
-`/commands` lists every builtin the registry holds, in registry order, one
-a line: its word, `arg` when it takes one, `root`, `pane` or `both` for the
-ctl that takes it, for a setting that chooses among words those words,
-comma-joined, then ` -- ` and one sentence of what it does (a builtin's doc
-comment's first sentence, a setting's doc), e.g.
+**Failure.** A write fails whenever what it asked for fails, and logs one
+`err <serial|-> <file>: <why>` record, with no `msg`. Only writes log: a
+refused open or truncation (an OTRUNC open such as `> data` after a failed
+`addr`), create or remove answers its error alone, as do a write to
+`pane/new` (`permission denied`) and a write on a read-only fid (`bad use
+of fid`). Errors are words (Plan 9's where pardes has none of its own),
+never a C library string. Through 9ns the kernel sees an errno 9ns reads
+from those words (cloud9 `9ns/src/nine.zig`, `enameToErrno`): `control
+message`, `invalid` or `bad ` is EINVAL (malformed input); `no such`, `not
+found` ENOENT; `in use` EBUSY; `no space` ENOSPC; `denied` EACCES; anything
+else, such as `no match for regexp`, `address out of range` or `Modified`,
+EIO. The `err` record has the words; a shell sees
+only the errno, most often `Invalid argument` or `Input/output error`.
-```
-Newcol root -- An empty column right of the keyboard's, its tag taking the keyboard.
-Save arg pane -- Write the pane's text to its file, or to the file its argument names.
-Verbose arg root on,off -- A builtin says its own name on the message row as it runs, on or off.
-Placement arg root acme,pardes -- Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules.
-```
+Not failures: a look that finds nothing (it answers nothing and logs one
+`err`), an Edit `x` that matches nothing, `Undo` with nothing to undo (a
+`msg`), and a command pane's command, which ends in its own time with an
+`exit` record.
+
+**Command lines.** `look`, `exec`, `tagexec`, the three kinds of `ctl` and a
+column's `exec` take one command a line. The whole write is checked first
+(a control character other than a tab fails it all, EINVAL); then lines run
+in order, and a failing line fails the write after the lines before it took
+effect, as acme's ctl does. Blank lines are skipped. A mount cuts a big
+write into pieces of at most one message (msize 8192, less the header), and
+each line runs once it is whole. A write that does not fill its message is
+whole, so its last line runs even without a newline (`printf Save > exec`). An `Edit` whose `{` or
+`a`/`c`/`i` text is still open waits for the next write on that open, and
+fails at the close if it never ends (``unmatched `{'``). A line held past
+1 MiB is refused.
+
+**Answers.** Reading `look`, `exec` or `tagexec` answers the serials the
+last command made, or else the pane it acted on or focused, one a line. An
+open that wrote reads its own last answer; an open that never wrote reads
+the session's last. A read is a stream: once read, the next read on that
+fid is EOF until the next write (or seek to 0). With other clients about,
+write and read on one open:
+`exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`.
+
+**Snapshots.** `/index`, `/layout`, `/recent`, `/commands`, `/status`,
+`/listeners`, a read-only `ctl`, `/log`, `/screen` and a terminal's `body`
+freeze at the open, so one read in several chunks never splices two moments;
+open again for now. Such an open, or a `run`, `event` or `pty/data` open,
+takes one of 64 open records; past that the open fails `too many open
+files`.
+
+**Held reads.** A read with nothing to give yet (a followed `log`, `event`,
+`pty/data`, `pty/run` before its answer) waits in the editor and is answered
+when news comes. A second read on that open meanwhile fails `file in use`.
+A read the client flushed is dropped. Through a FUSE mount bash's `read -t`
+cannot time out: wrap the loop in `timeout N`.
+
+**Stats.** Lengths are real (for `event` and `pty/data` the next record's,
+zero when none waits; for `log` what an open would freeze). The qid
+version of `body`, `data` and `xdata` is the pane's revision, so a stat sees
+an edit land. Modes are 0644/0666, 0444 read-only, 0222 write-only.
+
+## look and exec
+
+A line written to `look` is a right click on it:
+
+- a path opens the file (the pane already showing it, if any); `file:12`
+ selects line 12, newline included; `file:12:5` puts the caret at line 12,
+ byte column 5; `file:<addr>` takes any address (below), **evaluated from
+ the file's dot**: `file:/re/` finds the next match after the selection,
+ `file:0/re/` the first. `:addr` addresses the pane itself.
+- `@p<serial>:<addr>` addresses a pane by serial, a terminal's logical lines
+ too.
+- a directory types `ls` into a terminal idle there, else opens one there.
+- a URL opens in the browser.
+- a plain word selects its next place after dot, wrapping (`LookWord list`
+ on the root ctl lists every place in a `+Search` pane instead). In a
+ terminal a word is always listed, rows spelled `@p3:12:5-9`.
+
+A miss logs `err <serial> look: ...` (`no match for "zzq:#3"` quoting what
+was written when nothing by that name exists; `<path>: no match for regexp`
+or `address out of range` when the address fails), opens nothing, and leaves
+`look` reading empty; the write succeeds.
+
+A line written to `exec` is a middle click:
+
+- a builtin word runs (`/commands` lists them): `Save`, `Del`, `New`, `Tty
+ [shell]`, `Msg text`, `Find`, `Grep`, `Edit ...`, `Mount`, ...
+ A builtin that needs its argument (`Msg`, `Mount`, `Find`) written bare is
+ `wrong #args in control message "Msg"`.
+- acme's words run as pardes's where it has one (`Put` is `Save`, `Delete`
+ a `Del` that does not ask); the rest (`Get`, `Putall`, `Snarf`, `Cut`,
+ `Paste`, `Zerox`, `Sort`, `Load`, `ID`, `Send`) are refused, `invalid:
+ acme's Get is not a pardes builtin: ...`, never run as commands. So is a
+ GUI-only builtin (`Fonts`) on another frontend.
+- anything else is a command line, at most 1024 bytes. At a terminal idle
+ at an empty prompt it is typed into that shell. Anywhere else it runs as
+ a **command pane**: a terminal running the root ctl's `Shell` (`$SHELL`,
+ else `/bin/sh`) with `-c` and the line, in the pane's directory, with job
+ control on. Its tag reads `<dir> (<line>) running`, then `exit N`; the log
+ says `run <serial> <line>` and `exit <serial> <N|?>`; `exec` reads back its
+ serial. A typo ends `exit 127`. The directory's next command reuses a
+ finished command pane, below what it showed; one still running gets a
+ second pane. From a pane whose directory is gone nothing runs: `exec:
+ <dir>: no such directory` (ENOENT).
+
+The root's `look` and `exec` act at the active pane and log as that pane's;
+`/pane/<n>/look` and `exec` at pane n; `/tagexec` and `/col/<n>/exec`
+click in the workspace's or that column's tag, run commands in the session's
+directory, and log as `-`. A pane's word (`Undo`, `Msg`, `Save`) is refused
+at `/tagexec` and a column's exec: `not a session control message "Undo":
+write it to pane/<n>/ctl`.
+
+A background job (`&`) outlives a command that exits on its own; its output
+goes on below `exit N` until it lets go of the pty. `Kill` (root ctl)
+stops the commands pardes started: bare, all; `Kill make ls`, those whose
+line starts with one of the words. For a command pane it signals the whole
+line, `&` jobs included; for a line typed into a shell only the foreground
+job (SIGTERM), and the shell decides the rest. With nothing running it says
+`Kill: nothing running`. Kill does not reach a REPL's code: use `sig INT` on
+its `pty/ctl`.
+
+## The root ctl
+
+Reading `/ctl` gives every setting, one a line, in the words a write takes
+(`Theme orchard`, `Verbose on`, `Placement acme`, `DumpDir <dir>`,
+`Shell /bin/bash`, ...), so writing back what it reads changes nothing.
+Writes take settings and session builtins (`scope = .session` in
+`src/builtins.zig`), acting at the pane with the keyboard:
+
+- A setting written bare steps to its next value (a switch flips; so do
+ `Placement`, `BootShell`, `Crt`). A value it does not take is `bad value in
+ control message; ...` naming what it takes; `/commands` lists them. A
+ setting the frontend cannot show is refused (`Lift is GUI-only, invalid
+ here`).
+- `Newcol` makes an empty column right of the keyboard's, halving the active
+ column. `Joincol` folds the keyboard's column into the one on its right
+ (its panes go below that column's), `Joincol: no column to the right`.
+- `Exit` quits. It refuses once while panes hold unsaved text: one `unsaved
+ <serial> <name>` record per pane, then the write fails `<name>: Modified
+ (Exit again to discard)` or `4 unsaved panes: Modified (Exit again to
+ discard)` (EIO), and the list stays in a `+Unsaved` pane. The same word
+ again with nothing edited since discards; after more editing it refuses
+ again, naming only the panes edited since. `Restore`, `Del`, `Delcol` and
+ a pane's `get` refuse the same way with their own word. A `+New` scratch
+ under 100 bytes, or a command's output, is never asked about.
+- `Dump` writes `pardes-<date>-<time>.zon` (UTC) in `DumpDir`
+ ([config.md](config.md#dumps)) and logs `dump <path>`. `Restore [path]`
+ replaces every pane (bare: the last dump this session wrote). The Restore
+ write is answered, then **every connection is hung up**: dial again, and
+ restart a 9ns mount. The new log has a `new` per pane, `restore <path>`,
+ then `restored <old> <new>` per pane and `restoredcol <old> <new>` per
+ column. Undo history and REPL bindings are not restored; a command pane
+ comes back showing how it ended (`exit ?` if it was running) and does not
+ run again.
+- `Kill [word...]` (above), `Mount name dial`, `Unmount name`, `Theme x`,
+ `size <cols> <rows>`.
+- `size C R` sizes a `--detach` session no frontend is attached to (160x50
+ until then): from 20x6 to 4096x4096, else `invalid size`; refused while a
+ frontend owns the size, and when a column would lose its panes' minimum
+ rows (`size: too small for the panes, each its tag and 2 rows`).
+
+A write is refused whole, before anything runs, in Plan 9's words:
+`unknown control message "X"`, `wrong #args in control message "X"`,
+`bad value in control message ...`, or a word of the other ctl: `not a
+session control message "Undo": write it to pane/<n>/ctl`, `... "Delcol":
+write it to col/<serial>/ctl`, `not a window control message "X": write it
+to /ctl`. A builtin that would open a prompt (`Save` on a scratch) fails
+`control message needs its argument "Save"`. A line that fails as it runs
+fails with the editor's words (`Mount: already mounted`, `Save /root/x:
+access denied`), changing nothing.
+
+## Columns and tags
-Every builtin has its sentence; a test fails the build of one without. Such a setting written bare steps to its
-next value, so a two-valued one flips (`Crt`, `Placement`, `BootShell`), as
-its word clicked in a tag does; a value it does not take is refused with
-`bad value in control message; takes ...` naming those it does. It is
-generated from the registry, so it is always this build's own list.
+`/layout` has a line per column, left to right: `serial index x width
+current|notcurrent empty|full pane-serials...`, then `active <serial>`
+(acme's activecol: where `pane/new` and a look put the next pane; `-` when
+none). `current` is the column with the keyboard now, which may differ
+from the active one. `/index`'s last field is each pane's column serial.
-`/focus` reads the serial of the pane with the keyboard, and a serial written
-to it gives that pane the keyboard, off any column or workspace tag that had
-it -- rio's `current` written to a window's `wctl`, named once for the whole
-tree since there is one keyboard. While a column's or the workspace's tag has
-the keyboard no pane does: `/focus` reads empty and every pane's `ctl` says
-`notcurrent`. A write gives the keyboard and nothing else: a folded pane
-stays folded (unfold it with `Collapse` on its ctl), as rio keeps `current`
-apart from `unhide`. A serial no pane has fails with `no such
-pane`; anything but a number, with `ill-formed control message`.
+A session holds 16 columns (`no space for a column: 16 max`, ENOSPC), each at
+least 10 cells wide (`Newcol` from a column under 20 fails `this one is too
+narrow to split`). Since root `Newcol` halves the active column, reach 16 by
+writing `Newcol` to the widest column's `exec`.
-A pane is made by **opening** `/pane/new`, and closed by Tremove on
-`/pane/<n>` (`rmdir`), which is the only remove the tree serves; Tcreate is
-refused everywhere, as it is in acme. Reading the open fid answers the serial
-of the pane that open made, so `n=$(cat /pane/new)` makes one and names it in
-a line. Each open makes another pane, and two reads of one fid answer the same
-serial: the open acted, the read only observes. Closing the fid leaves the
-pane.
+`/col/<n>/ctl` takes `Delcol`, `Joincol`, `New` and `Tty` for that column;
+`/col/<n>/exec` is a click in its tag; `rmdir /col/<n>` closes an empty column
+(one with panes: ENOTEMPTY). A column may be empty, as in acme: `Newcol`
+makes one, and closing its last pane leaves it (the keyboard goes to its
+tag, `focus` reads empty, the log says only `del`). Closing the session's
+last pane quits pardes. The log says `newcol <serial>` and `delcol <serial>`.
-This is `/net/tcp/clone`'s mechanism, not acme's `new`, and the difference is
-deliberate. acme allocates during the *walk* and lets the walk land inside the
-new window, so `/dev/new/body` works in one step (acme(4): "accessing any file
-in `new` creates a new window"). acme can also afford to list `new`, because a
-Plan 9 directory read carries the stat of every entry and nothing walks. A
-kernel or FUSE mount is not so lucky: it walks and stats each name a listing
-gave it, so an allocate-on-walk name would make a pane per `ls -l`. Allocating
-on open instead keeps `new` listed and `ls` honest — a stat is not an open —
-at the cost of acme's one-step `new/body`. Nothing in the tree is created by
-list, stat, walk or read; only that one open. Every other name in `/pane` is a
-serial.
+`tag` files (`/tag`, `/col/<n>/tag`, `/pane/<n>/tag`) read the whole tag.
+A pane's starts with its computed path (an image's with its mode words, a
+PDF's with its page), then the editable text. `>` replaces the editable text
+(the default words too: `echo Make > tag` leaves only `Make`) and drops the
+one newline that ends it; `>>` appends, so `printf ' Make' >> tag` (the
+blank matters; `echo` would start a second line). A pane tag may hold
+several lines; a column or workspace tag is one, a newline written into it
+becoming a space. Control characters other than tab, DEL, C1 controls and
+non-UTF-8 bytes are refused (`invalid tag text`). The editable text is at
+most 4096 bytes (`tag: no space: over 4096 bytes`, ENOSPC). A clear is an ordinary edit
+and `u` in the tag undoes it. [tags.md](tags.md) covers tags on screen.
-A session can open its own tree through a mount: a Look at
-`/mnt/9p/pardes/<me>/pane/2/body` from inside that very editor opens it, and
-a Save of that pane writes back through the mount into pane 2. Requests on
-the Unix and TCP listeners are answered on the 9P connection's own task, not
-by the editor's loop, so the realpath, the stat and the read the editor makes
-out through the mount come back while it waits for them. The one rule is
-whose turn it is with the core (`pardes.turn`): the editor has it, and gives
-it up while it waits for input and while a step of it is out in a syscall. A
-step of a connection task's own -- a Look written to `look` -- goes out the
-same way, and the editor waits for it to return before it takes a step of
-its own. While any step is out, a request that would change a pane (a write,
-a truncation, an rmdir) parks in the engine until none is; everything else,
-opening `pane/new` and `screen` included, is answered at once, which is why a
-Look at any path in the tree comes back. A write into the tree from the
-editor itself only ever happens between steps (a Save), so nothing it waits
-on out there is a request that has to park. QUIC is still served on the
-editor's loop, so through QUIC the old hang remains. `/n/self/...` names the
-same tree without leaving the process.
+## Panes
-`/look` and `/exec` are the editor's two clicks, one per line of a write:
+**Making and closing.** Opening `/pane/new` makes a scratch `<dir>/+New`
+(the session's directory), and reading that open answers its serial:
+`n=$(cat $m/pane/new)`. Each open makes another pane; two reads of one open
+answer the same serial. It goes where acme's makenewwindow puts a window
+([tags.md](tags.md#where-new-panes-go)): in the active column, filling it
+if empty, else the bottom half of its last pane. A session holds 64 panes
+(`no space for a pane: 64 max`), and every pane keeps its tag and 2 rows
+(`no space for a pane in that column: each keeps its tag and 2 rows`); both
+are ENOSPC, for this open and for a look, exec, `New` or `Tty` alike.
+`rmdir /pane/<n>` closes the pane, unsaved or not.
-- a line written to `look` is a right click on it: a path opens a file,
- `file:12` jumps to a line, a directory types `ls` into a terminal idle
- at an empty prompt there, else opens a terminal there that runs
- `ls` once (pardes has no directory listing pane of acme's kind; a shell
- in it is where one goes on from a directory), a URL opens in the
- browser, and a plain word, as acme's look3 does, selects its next
- place in that pane after the dot, wrapping at the end, opening nothing
- (`LookWord list` on the root ctl lists every place in a `+Search` pane
- instead, as pardes did before; `LookWord search` is the default). In a
- terminal, which has no dot to go on from, a plain word is always listed
- in a `+Search` pane, its rows naming the terminal as
- `@p<serial>:<line>:<cols>`, the columns a range, `@p3:12:5-9` (bytes 5
- through 9 of logical line 12, 1-based, both ends in), which a look of the
- row selects,
- and look reads that pane back.
-- a line written to `exec` is a middle click: a command word from
- `src/builtins.zig` (`Save`, `Del`, `New`, `Newcol`, `Mount NAME DIAL`,
- `Unmount NAME`, `Dump`, `Restore`, `Msg TEXT`, `Find`, `Grep`, `Tty`, ...;
- `Tty`'s argument is the shell it runs, `Tty fish`, and `Tty` on a pane's
- ctl opens a new terminal pane, not in that one: in the active column, as
- acme's makenewwindow puts a new window (util.c:456-467: the active column
- first, then the pane's own), under the text with room or halving the
- tallest, never shorter than its tag and 2 rows),
- or anything else, a command line. Written at a terminal idle at an EMPTY
- prompt it is typed into that shell -- any line that is no builtin, so
- `Delcol x` at a terminal goes to the shell -- (and a terminal whose shell
- exits, `exit` typed or run, closes its pane). Nothing is ever typed over
- text someone typed at a prompt and did not send. From anywhere else -- a
- file, a scratch, a tag, a terminal with a line typed at its prompt or
- whose tty a program holds -- it runs as a command pane (from a pane whose
- directory is not there it runs nothing and makes no pane, `exec: <dir>:
- no such directory`, ENOENT, as `Tty` there does; one whose shell the host
- cannot start ends at once, `exit <serial> 127` in the log, its tag no
- longer running, nothing for Kill): a
- terminal whose child is the root ctl's `Shell` ($SHELL, else /bin/sh, unless set) run
- with `-c` and the line, in the pane's directory, with job control on
- (bash, sh, dash, zsh, ksh `-m`; fish `status job-control full`),
- which shows its output and then `exit N` (its tag reads `<dir> (<line>)
- running`, then `exit N`), and stays. The command is over when its process
- exits, as in acme, not when its terminal closes: a job it left in the
- background prints on below `exit N` until it lets go of the pty, and a
- command that lets go of its terminal early runs on to its own exit. A
- background job outlives a command that exits on its own (Kill stops it too): job control gives it a process
- group of its own, so the hangup the kernel sends the terminal's
- foreground group when the shell exits misses it. It survives the pane
- closing too, but its writes to the terminal then fail, so start one that
- must keep writing with `nohup` or its output redirected. The directory's
- next command runs in that pane once it is done and nothing holds its pty, below what it showed, after a `% <line>` line;
- one still running gets a second pane. Before the next command the pane
- leaves any alternate screen and turns off the modes a program left on
- (mouse reports, bracketed paste, a hidden cursor); a command that clears
- the screen and its scrollback (`clear`, ED3) erases the history above it. A command pane's own exec starts
- the next command there too. The log says `run <serial> <line>` and `exit
- <serial> <N|?>`; `exec` reads back the command pane's serial; Kill stops
- its whole line, `&` jobs included; a command line is at most 1024 bytes, and a
- longer one written to an exec fails the write (EINVAL, `invalid command
- line: a command line is at most 1024 bytes`, and one with a control
- character (DEL too) but a tab, `invalid command line: it holds a control
- character (or DEL) other than a tab`; the root's exec logs either against the pane it would
- have run at) before anything in it runs; a builtin's line (a long
- `Msg`, an Edit block) may be longer. To run a command again, execute
- its line again from its directory: `echo 'make test' > pane/<n>/exec` on
- the command pane runs it there, below the last run. A misspelled word is a
- command that says so and ends `exit 127`. `echo Tty > pane/<n>/ctl` makes
- an interactive terminal in that pane's directory.
+**`name`** reads the file name (a terminal's directory); a write renames the
+buffer (relative to the pane's directory) and marks nothing dirty; `Save`
+then writes under the new name. A name is one line; refused (EINVAL) are a
+second line, a blank at either end, control bytes and non-UTF-8 (`bad
+character in file name: a blank at its start`, ...). Up to 255 bytes a
+component.
-A terminal can be bound as a language's REPL: `Repl python` in its tag or
-on its `ctl` (the language names are the syntax table's, or a code fence's
-alias such as `py`, in any case; `Repl -` unbinds,
-`Repl` bare says the binding, `Repl python` again changes nothing). Its tag
-shows its id, `python-a`, `python-b` for the next, a freed letter reused,
-and so does the end of its `ctl` line, after `current`/`notcurrent`.
-A builtin's word still runs first, bound or not: `Del` clicked in the file
-closes its pane. Then any other exec made by a gesture on the body of a file
-in that language -- a
-middle click, the execute key, on a selection or a single word, even `make`
-in a comment -- or on the REPL's own body is typed into the REPL instead of
-run: bracketed paste when its program asked for it (DECSET 2004), else line
-by line, where a blank line inside a Python block ends the block (said
-once), then Enter. The message row says `→ python-a` in the tag's name tint
-and the log `send <from> <to> <id>`. With several REPLs bound for the
-language the pane asks which, on its notice band, one key answering and Esc
-sending nowhere; nothing is remembered. A REPL takes text only while its
-program has the terminal: a command pane's until its command is done (the
-binding goes with it, and a done one cannot be bound), an interactive
-terminal's while a program other than its shell holds the tty -- after
-Ctrl-D the shell would run the text, so nothing is sent and the pane says
-so. Still commands, whatever is bound: a word in a tag (so
-the tag is how to run `make` from that file), `Exec <text>` run by name
-(typed, a 2-1 chord onto `Exec`, a `ctl` line) -- in a `.py` pane bound to
-a REPL, `Exec print(1)` runs `print(1)` as a command, never in the REPL --
-and a command word @`cmd` in
-the text, looked at or clicked (`# @`pytest -x`` in a script). A 9P `exec`
-is no gesture and is never sent: a script writes to the REPL pane's
-`pty/data`, multi-line code as a bracketed paste (`\e[200~<code>\e[201~`,
-then `\r` in a write of its own once the REPL has echoed the paste --
-Python 3.13's REPL takes an Enter read with the paste as part of it, even a
-one-line one -- and, for a paste of more than one line that does not end
-in a newline, a second `\r`: one Enter leaves a multi-line input at `...`; a middle click does all of this
-itself, holding the Enter until the REPL answers the paste), since line by line a blank line ends a Python block and Python
-3.14's REPL auto-indents each line typed into it. The REPL gets the text
-wherever it is -- at a `pdb` or `input()` prompt too. Over 9P, a range of a
-`.py` pane goes to its bound REPL as a click would: write the event record
-`MX<q0> <q1>` back to the `.py` pane's `event` (a range past its end is
-refused, `range past end of body`). `Kill` does not stop
-what a REPL runs, since pardes did not start it; `sig INT` on the REPL
-pane's `pty/ctl` interrupts it as Ctrl-C would. Bindings are not dumped, so a Restore leaves none.
+**`body`** reads the text; a write appends; `>` (OTRUNC) replaces it all.
+A terminal's body is its history as plain text in logical lines (wrapped
+rows joined), frozen per open; writing it sends input to the child. A PDF's
+body is the text layer of the page shown; images and PDFs take no write
+(`this pane has no text`).
-The root's pair clicks at the active pane and `/pane/<n>/look` and
-`/pane/<n>/exec` at that pane; `/tagexec` and `/col/<n>/exec` click in the
-workspace's or that column's tag (never an event reader's, which hears
-only its pane's; a command they run starts in the session's directory,
-where pardes started, not the focused pane's), and what the word says is logged as the session's,
-`msg -`. Blank lines are skipped, and every other line
-is checked before any of them runs, so a control character fails the whole
-write with EINVAL; a builtin that fails there fails the write as well
-(below: one rule). Reading any of these files answers the
-serials of the panes the last command created, or, when it created none, the
-pane a look focused or the pane an exec acted on (even one it closed), one
-per line; a look that found text answers the pane the text is selected in,
-not the hits buffer it opened.
+**`sel`** reads the selected text; a write replaces it. **`errors`** is
+write-only: text appended to the directory's `+Errors` pane (logged as
+`msg` records when no column has room).
-Reads are a stream: once an open has read the answer, a second read on it
-gives EOF (on an open that wrote, until its next write); open it again, or
-seek to 0, to read it again. The answer belongs to the open, as
-/net/tcp/clone's does: an open that
-wrote reads what its own last write touched, from the start after each
-write, whatever offset the read comes at (a shell's `exec 3<>look` shares
-one offset between its write and its read, as with `pty/run`); an open
-that never wrote reads the session's last answer as it stood when it was
-opened. So `echo x > look; cat look` works for one client, but two
-clients doing it at once may read each other's; for that, write and read
-on one open: `exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`.
+**`focus`** (root): a serial written gives that pane the keyboard and makes
+its column active (a folded pane stays folded); `no such pane`, or
+`ill-formed control message` for a non-number. It reads empty while a
+column or workspace tag has the keyboard.
-A write of command lines -- to `look`, `exec`, `tagexec`, a `ctl` of the
-root, a pane or a column, or a column's `exec` -- runs each line once it is
-whole: a mount cuts a big write at its message size (4 KiB through the
-kernel's, 8 KiB from a client that asks), anywhere, and each piece comes as
-a write of its own. A write that fills its piece may go on in the next, so
-the open keeps its last line with no newline until then; a write shorter
-than a piece is the whole of what was written, as acme takes each write, so
-its last line runs with it even with no newline (`printf Save > exec`), and
-a failure is that write's. Only what needs more is held: an Edit block
-whose `{` or `a`/`c`/`i` text has not ended waits for its next write, and
-what is left when the file closes runs at the close -- where an Edit block
-whose `{` or `a`/`c`/`i` text never ended fails and changes nothing
-(``unmatched `{'``, or `a, c or i text not ended by a . line`, logged as an
-`err`): sam takes the end of input for a `.`, but a block that reaches Edit
-unfinished was cut short. A fragment never runs on its
-own. A line or Edit block held past 1 MiB is refused (EINVAL).
+**Pane ctl.** Reading it gives acme's status line: serial, tag length, body
+length, isdir (0), dirty, width in cells, font, tab width, undo available,
+redo available, then `current`/`notcurrent` and a REPL's id if bound. It
+takes:
-`@p<serial>:<address>` addresses the pane with that serial as `file:<address>`
-does a file, with any sam address (`/re/`, `?re?`, `#n`, `$`, `0/re/`, a range),
-a terminal too: its body is then the text of its logical lines (the lines
-a `+Search` of it lists), addressed from its cursor, and the match is
-selected. A miss, and a serial no pane has (with a line, `@p77:3`, as
-with any other address), each log an `err`, and look reads back empty. A look takes acme's addresses after a colon (editors/acme/look.c:450): a
-line written to `look` as `file:/re/`, `file:?re?`, `file:#n`, `file:$` or any address
-opens (or finds) the file and selects what the address names. **The
-address is evaluated from the file's dot**, as acme's is: `file:/re/`
-finds the next match after the current selection, not the first in the
-file. For the first, start at the top: `file:0/re/` (or `file:#0/re/`).
-A pattern that can match empty, such as `^`, passes over the empty match
-at #0 as sam does (a search never answers where it started), so it finds
-the next one: for the start itself write `file:0` or `file:#0`.
-`:addr` does the same in the pane itself, and a pattern may hold blanks
-(`calc.py:/return a/`). `file:12` selects line 12, its newline included,
-as acme's does; `file:12:5` puts the caret at line 12, column 5 (columns count bytes from 1,
-as `addr`'s `12:5` does and as the rows of Grep, +Search and the language
-servers write them). A bare
-`/re/` is a path, as in acme, and failing that a search for its text. A
-look that finds nothing, an address that does not evaluate, or a line past
-the file's end (`calc.py:99`) says so on the message row and in the log
-(`err <serial> look: ...`), focuses and opens nothing, keeps the
-selection, and leaves `look` reading back empty.
+- the pane builtins: `Del` (`Del k`/`Del j`, or `DelAbove`/`DelBelow`, give
+ its rows to the pane above or below), `Save [path]`, `Collapse` (fold),
+ `Undo`/`Redo` (256 steps each), `Find pat`, `Edit ...`, `Tty [shell]`
+ (a new terminal pane in its directory), and the column words acting on
+ its column: `Delcol`, `Left`/`Right`/`Up`/`Down`.
+- `get`: reload from the file (refused once over unsaved edits, `<name>:
+ Modified (get again to discard)`).
+- `lock`/`unlock` (acme's). The lock binds only clients that take it and
+ belongs to the open that wrote it: `exec 3>$pane/ctl; echo lock >&3; ...;
+ exec 3>&-`. A `lock` another open holds fails at once, `file in use`
+ (EBUSY): retry.
+- `answer <choice>` to the question the pane asks on its notice band, logged
+ `ask <serial> <what> <choices>` (`ask 4 del k j`, `ask 4 repl a b`, `ask 4
+ save path`); `answer -` takes it back.
+- acme's lowercase ctl words, done by what replaces each: `name x`, `put`
+ (Save), `clean`/`dirty`, `del`, `delete` (no asking), `dot=addr`,
+ `addr=dot`, `limit=addr`, `mark`/`nomark`, `show`, `cleartag`. `dump`,
+ `dumpdir`, `font`, `menu`, `nomenu` are refused, EINVAL. These lowercase
+ words are ctl-only: written to an `exec`, `del` is a command line.
-`/pane/<n>/name` reads the pane's file name (a terminal's directory) and
-writing it renames the buffer; a relative name resolves against the pane's
-directory. A name alone is no edit: the pane's `dirty` stays what its text made it
-(a renamed clean file is still 0, and nothing asks about it at Exit,
-Restore or Del, which ask only about text edited), and `Save` writes it
-under the new name all the same. An open's writes are one name: held
-until its newline, applied once however the writes cut it; a write with
-no newline that is whole in its Twrite is the name then, so a bad one fails
-that write, not the close. Nothing else is trimmed. Two lines are refused, EINVAL,
-in one write or as a second line on the same open (bash's `printf
-'a\nb\n' > name` writes a line at a time: the first names it). A blank inside a name is taken
-(`two words.zig`); refused, EINVAL, in acme's words (xfid.c:650) and why,
-are a blank at either end (not quietly cut off), `bad character in file
-name: a blank at its start` (or `end`), a second line, `...: a newline (a name is one
-line)`, a control byte, DEL or a C1 control (U+0080-U+009F), `...: a
-control character`, and bytes that are not UTF-8, `...: not UTF-8`. `body` appends on write and replaces on truncating open. A
-terminal's `body` is its history as plain text, frozen per open, in logical
-lines: a row the terminal wrapped is joined back to the row before it (the
-wrap is ghostty's, as `pty/run`'s output unwraps), and the last line ends
-with a newline. `sel`
-reads the selected text and writing it replaces the selection, leaving dot
-just past the text written (a `data` write moves dot as its text moves it,
-so a dot at the address it wrote at ends just past that text too). `errors`
-appends to the directory's `+Errors` pane; with no room for that pane in
-any column, what it would have shown is logged as `msg` records instead, a
-line each, and the write still succeeds. Holding `event` open redirects the
-pane's Look and Exec clicks to that client, and so does a line written to the
-pane's own `look` or `exec`, or to the root's while that pane has the
-keyboard (never its `tagexec`, which is a click in its tag and runs), a
-click with no place in the text: an `F` record at `0 0` carrying
-the line. So a client holding `event` that wants a command run gets its own
-exec back as a record: it runs it through `ctl`, or writes the record back.
-Writing a record back performs the action, as the click would have (acme's
-xfideventwrite): a body `X` goes to a REPL bound for the text as a middle
-click does, where an `F` record, a line written to `exec`, runs as the
-command it was. acme takes back only `<origin>
-<action><q0> <q1>`, the text of that range; pardes takes the record whole as
-it was read too, and for an empty range acts on its text, which is how such
-a line is done. A click in a file's body carries the offsets of the text it
-took; one in a terminal's body cannot, since that body is a history
-snapshot, and is also at `0 0` with its text. A click that takes no text
-sends nothing, as in acme. `ctl` reads acme's window status line, field for
-field as plan9port's — serial, tag length, body length, isdir (0), the dirty
-flag, the width in cells, the font, the tab width, whether Undo has a step
-(1) and whether Redo has one — followed by pardes's own: rio's `current` or
-`notcurrent` (rio(4), `wctl`), whether the pane has the keyboard, and a
-REPL's id. It takes the pane's builtins (below),
-`get`, which reloads the buffer from the name it
-carries (unsaved edits are refused once, listed in `+Unsaved` with a short
-notice as Exit's are, the write failing `<name>: Modified (get again to
-discard)`, as acme's get asks winclean, exec.c:513). A file that changes on
-disk reloads by itself only into a buffer with no unsaved edits; one with
-them keeps its text and stays dirty, says `<name> changed on disk (get
-reloads it, Save overwrites it)` and logs `changed <serial>`, and then its
-`Save` warns once, `<name> modified on disk since read (Save again to
-overwrite)`, as acme's Put does (exec.c:577) -- acme reloads nothing by
-itself. `answer <choice>`
-for the question the pane asks on its notice band, which the log names as
-`ask <serial> <what> <choices>` -- `ask 4 del k j` for Del's side from the
-keyboard (`k` the pane above takes the rows, `j` the one below), `ask 4
-repl a b` for which bound REPL takes an exec (a REPL's letter) -- `answer -`
-taking it back as Esc does (a choice the question does not offer is refused,
-naming those it does, and the question stands; with no question standing,
-`answer` is refused),
-and acme's `lock` and `unlock` (editors/acme/xfid.c:603-611), for an
-edit of several writes to `addr` and `data` that another client must not
-land in the middle of. As in acme the lock binds only the clients that take
-it: a `lock` while another open holds it fails at once with `file in use`
-(EBUSY), to be tried again, until that open writes `unlock` or closes (or
-the pane does) -- where acme's blocks, because through a kernel or FUSE
-mount a blocked write would hold up the holder's own `unlock` and close on
-that file -- and nothing else is refused for it --
-not a write to any other file, not the person at the keyboard. It belongs to
-the open that wrote it, so only that open's `unlock` is taken; a write on an
-open that cannot write (or the editor's own, on none) cannot lock. From a
-shell the lock needs an open held across the edit, since `echo lock > ctl`
-closes, and so unlocks, at once: `exec 3>ctl; echo lock >&3; ...edits...;
-exec 3>&-`.
+A file changed on disk reloads by itself only when the buffer has no unsaved
+edits; otherwise it stays dirty, says `<name> changed on disk (get reloads
+it, Save overwrites it)`, logs `changed <serial>`, and its next `Save`
+warns once.
-The three range files `addr`, `dot` and `limit` each read the pair of offsets
-they also accept, so copying one onto another is all that acme's `addr=dot`,
-`dot=addr` and `limit=addr` ever were. A write is either that pair or an
-address expression (`#0,#5`, `/pattern/`, `2+1`, and pardes's own `12:5`,
-below); `addr` selects where `data` reads from -- to the end of the text,
-as acme's does, so `cat data` reads everything after the address -- and
-the range `xdata` reads, just that; either replaces the range, `dot` is the editor's own selection and moving it
-scrolls the pane into view, and `limit` bounds only the end of a forward
-search, as acme's does, and reads empty until it is set. Truncating `dot` empties it, truncating `limit` lifts it --
-though a write after the truncation that fails puts the old limit back, so
-`echo /bad/ > limit` changes nothing -- and truncating `addr` leaves it as
-it is (below).
+### Addresses and data
-A rename everywhere, or any other sam edit, is one write to the pane's
-`ctl`: `Edit ,x/foo/c/bar/` runs acme's Edit (docs/tags.md) on the body as
-one undo step; a failure fails the write with acme's words and changes
-nothing. A write is one message a line, except that an `Edit` line takes
-the lines after it while its `{` group is open or its `a`, `c` or `i` text
-block waits for its `.` line, on a pane's `ctl`, the root's (at the active
-pane) and `exec` alike; an unclosed group is refused (``unmatched `{'``).
-A block may come in several writes on one open (bash's builtin `printf`
-writes a line at a time): the open holds it until it ends (below).
+`addr`, `dot` and `limit` read the pair of byte offsets they take, so
+copying one onto another (`cp $p/addr $p/dot`) is acme's `dot=addr`. A
+write is a pair or an address expression. `addr` names where `data` reads
+(to the end of text) and the range `xdata` reads; `dot` is the selection
+(moving it scrolls the pane there); `limit` bounds the end of a forward
+search and reads empty until set. Truncating `dot` empties it, truncating
+`limit` lifts it.
-A line number counts newlines as sam's lineaddr does (editors/sam/
-address.c:180), so the empty line just past a text's last newline is an
-address: `1` of an empty text is `#0,#0`, `2` of `a\n` is `#2,#2`, and
-`Edit 1i/header/` on an empty file inserts; a line past that is `address
-out of range`.
+- A write to `data` or `xdata` **replaces** the `addr` range (`>` and `>>`
+ alike); `: > data` deletes it. Truncating `data` is pardes's own (acme
+ ignores OTRUNC there); only truncating `body` empties the buffer.
+- A write leaves `addr` just past what it wrote, so a second `echo x > data`
+ inserts after the first: write `addr` before each replacement. A read of
+ `data`/`xdata` moves `addr` past what it read.
+- `addr` belongs to the pane, not the client, and neither an open nor a
+ truncation resets it (acme resets on first open): each `echo /re/ > addr`
+ searches on from the last, so a find loop advances. Write `0` to start
+ at the top; searches wrap, so stop a loop when the address comes back or
+ set `limit`.
+- A failed address leaves **no address**: `addr` reads empty and `data`/
+ `xdata` refuse (`no address: the last one written to addr failed`) until
+ a standalone address (`2`, `#0`, `/re/`) is written, so a missed target is
+ never written at the old one.
-`line:col` is a pardes extension to sam's addresses, the spelling Look
-takes in `file:12:5`: `12:5` is the point at line 12, column 5, and it
-composes like any simple address (`12:5,14:1`, `12:5+#3`). The column is
-in bytes from 1, as Look's is, clamped to the end of the line and snapped
-back to the start of the rune it falls in; a line past the end, or
-column 0, is `address out of range`. Since the column counts bytes, a
-column a tool gives in characters (pytest's, a compiler's) is the same
-only on an ASCII line; elsewhere address the line and search it
-(`12/name/`) or use `#n`. sam would read `12:5` as a syntax
-error. The rows of Recent, a +Search and the Jumplist spell a range
-`12:5-14:2` (or `12:5-9` on one line), and `addr` takes that too, so a row
-pastes in; the two spellings side by side:
+Addresses are sam's: `#n`, a line number, `/re/`, `?re?`, `-/re/`, `$`,
+`.`, `0`, ranges `a,b` and `a;b`, `+`/`-`. They are evaluated from the
+current address (the last written to `addr`, or just past the last `data`
+write), not from the selection. pardes adds `12:5`: line 12, **byte**
+column 5 from 1, clamped to the line's end (`12:0` is `address out of range:
+a column counts from 1`); it composes (`12:5,14:1`). A tool's character
+column matches only on an ASCII line; elsewhere use `12/name/` or `#n`. A
+row of Recent, `+Search` or the Jumplist spells a range `12:5-14:2` (through
+14:2 inclusive) and `addr` takes it as such.
- 12:5,14:2 sam's range: from line 12 column 5 up to the point at 14:2
- 12:5-14:2 a row's range: 12:5 through the character at 14:2, inclusive
+Offsets and counts are bytes everywhere (acme counts runes), but every
+address lands on a rune boundary: `#n` or `L:C` inside a rune snaps to its
+start, a match covers the runes it touches, and a combining mark or a CRLF's
+`\r` is addressable alone.
-A `-` right after `L:C` and a digit is this range, not sam's "back N
-lines" from that point.
-A write to `data` or `xdata` replaces the range `addr` names, as acme's
-does (editors/acme/xfid.c:491-523: it deletes the range, then inserts), so
-`echo NEW > data` and `echo NEW >> data` both replace that range, and
-truncating deletes it and nothing else, so `: > data` deletes it; only
-truncating `body` empties the whole buffer. Truncating `data` is pardes's
-own: acme ignores OTRUNC there (editors/acme/fsys.c:543). And as in acme a write
-leaves `addr` just past what it wrote, so a second `echo x > data` deletes
-the empty range there and inserts after the first rather than replacing it
-again; write `addr` before each replacement. A read of `data` or `xdata`
-moves `addr` past what it read, as acme's does.
-`addr` belongs to the pane rather than to a client and keeps what was written
-until someone writes another, so writing an address and reading it back
-evaluates it, which is what acme(4) promises of its own `addr`. Unlike acme,
-neither an open nor a truncation resets it: acme sets it to `#0` when the
-first client opens `addr` (editors/acme/xfid.c:105-108), which suits a
-client that holds the fid, but a shell opens the file anew for every
-`echo /re/ > addr` and so would search from the top each time and never
-advance. Here each such write searches on from the last address, as `>>`
-does; write `0` to start again from the top. A search wraps at the end of
-the text, so a find-all loop stops when the address comes back to where it
-began, or bounds itself with `limit`.
+Refusals: `bad address syntax`, `no match for regexp`, `address out of
+range`, `addresses out of order` (`#100,#50`), `bad regular expression`.
-The regular expressions are mvzr's (sets, `\d`/`\w`/`\s`, `{m,n}` and
-lazy `*?` included), searched the way sam searches (editors/acme/regx.c): as
-lines, so `^` and `$` match at the start and end of any line, `.` and a
-negated class never match a newline, and `$` also matches at the end of a
-text with no final newline. A pattern that names a newline (`\n`) runs over
-the whole text instead, its `.` kept to one line; there a leading `^` still
-matches at every line start (`$` on a CRLF line sits before the `\r`,
-as the line's end is its `\r\n`), and `$` may stand just before a `\n` (where it
-changes nothing). Any other `^` or `$` in such a pattern, `(^|\n)def` or
-`a\nb$`, is refused, EINVAL, with `bad regular expression: in a pattern with \n, ^ can only come first and $
-only just before a \n`, since mvzr would read it as the start or end of the
-whole text: never a search that silently finds nothing. An alternation
-whose every branch starts with `^` (`^def|^ `) finds a line that starts
-either way (it is taken as `^(def| )`); one that mixes anchored and
-unanchored branches (`^def|x`) is refused, `bad regular expression`, since
-mvzr keeps `^` only first. A pattern may be up to 512 of mvzr's operations,
-about 512 characters (mvzr's own is 64; pardes builds it with more); a
-longer one is refused, `bad regular expression: longer than mvzr's 512
-operations`. A class may hold non-ASCII runes (`[éa-z]`, `[à-ÿ]`): it is
-taken as an alternation of them (`(é|[a-z])`), a range of up to 256 runes
-spelled out, and the 512 counts the pattern as rewritten. A wider range
-(`a range of runes wider than 256 in [...] is not supported`) and a negated
-class with a non-ASCII rune (`[^é]`) are refused, since mvzr's classes hold
-bytes. An expression is
-evaluated from the current address, the range last written to `addr` (or
-left by the last `data` write, just past it), as acme evaluates it from
-`w->addr` (xfid.c:446): `.` is that address, not the selection (`dot` is
-the selection's own file), and `#9/re/` searches from `#9`; in `/a/;/b/`
-the second search starts at the end of the first, as acme's `;` does, where
-`/a/,/b/` starts both at the current address. `/re/` searches
-forward from the end of the current range to `limit` if one is set, and
-otherwise wraps to the start of the text; `?re?` and `-/re/` find the last
-match ending before the range, wrapping to the text's last. The match is the leftmost, but of
-the alternatives at that place mvzr takes the first that matches where sam
-takes the longest (`/gam|gamma/` finds `gam`); in a search begun in the
-middle of a line, `^` inside a group can match there; and in a
-pattern that spans lines, `^`, `$` and `[^...]` keep mvzr's own meaning.
-mvzr backtracks without bound of its own (`a?` twenty times then twenty
-`a`s is 2^20 steps from each place it tries), and a search holds the editor, so pardes patches a
-step budget into mvzr's matcher (build.zig): a search that spends it,
-about 300 ms, fails with `regular expression search took too much time,
-gave up` rather
-than answer a match it is not sure of. The budget is each search's: an
-Edit `x` over 100k lines makes 100k searches, each with its own. Ordinary patterns spend a few
-thousand steps; what runs out is exponential backtracking, and a quadratic
-pattern over a very long line (`\s*(\w+)\s*=` over 20 KB of letters).
-pardes has no regex engine of its own on purpose; these are its limits.
-Normal mode's `s` and `S` search a selection the same way (src/regexp.zig
-is the one place both call), so `^` there also means a line's start.
+sam details kept: `$-1` is the last line when the text ends in a newline,
+else the one before; with a final newline the empty place after it is a line
+(`1` of an empty text is `#0,#0`, so `Edit 1i/x/` works on an empty file);
+`2,1` is an empty range at line 2's start; `/^/` finds the empty place after
+a final newline; a pattern that can match empty (`^`) passes over the match
+where the search starts.
-An address that does not evaluate fails the write with why: `bad address
-syntax`, `no match for regexp`, `address out of range`, `bad regular
-expression`, `regular expression search gave up, ...`, or sam's
-`addresses out of order` for a range that ends before it starts
-(`#100,#50`), which acme lets through. Each end is checked first, so in a
-text shorter than 100 bytes `#100,#50` says `address out of range`. A failed write to `addr` leaves no
-address at all, where acme
-keeps the old one: until a good address is written, `addr` reads empty
-(as an unset `limit` does), and reading, writing or truncating `data` and
-`xdata` fail with `no address: the last one written to addr failed`, so a script that
-missed its target cannot then write at the last one; so does an address
-written to `addr` that goes from the current one (`.`, `+1`, `-/re/`), which
-there is none of, until an address that stands alone (`2`, `#0`, `/re/`) is.
+### Regular expressions
-The three flag files `dirty`, `mark` and `scroll` read `0` or `1` and take
-`0` or `1`: whether the buffer differs from its file (a file deleted on
-disk counts, as in acme: its text is only here now, and Del, Exit and the
-rest ask first), whether a write pushes
-an undo point (writing `1` pushes one now), and whether a write scrolls the
-pane. The writes of one open of `data`, `xdata` or `body` are one undo
-step while `mark` is 1 (so `printf 'a\nb\n' > data` is one, though a
-shell writes it a line at a time), and the next open starts another. The
-pane has one history, so two opens writing at once take turns in it: each
-open's first write after the other's starts a step of its own; to
-make a loop's writes one step, write `1` (an undo point here), then `0`,
-the writes, then `1` again. An open's writes in a row to one place -- an
-append to `body`, inserts going on at `data`'s address -- are held and go
-in as one edit when anything else comes (another request, the close, or
-the editor's step once they pause 20 ms), so a 10 MB write is one copy, not
-one per 8 KB piece; any read sees them.
+Patterns are [mvzr](https://github.com/mnemnion/mvzr)'s (classes, `\d\w\s`,
+`{m,n}`, lazy `*?`), searched as sam searches, line by line: `^` and `$`
+match at any line's start and end, `.` and `[^...]` never match a newline.
+The same code (`src/regexp.zig`) serves addresses, Edit, and normal mode's
+`s` and `S`. The ceiling:
-One rule for what fails: a write fails whenever what it asked for fails,
-whether it came to a `ctl` (the root's, a pane's, a column's, a pane's
-`pty/ctl` -- its `exec` included), `look`, `exec`, `tagexec` or a
-column's `exec`, or to any other file -- with an
-errno that fits, EINVAL for malformed input (an unknown word, a control
-character, a command line over 1024 bytes, a `size` or `winsize` out of
-range, a bad address or event record), else EIO or the errno the words
-name (ENOENT for a pane, file or directory gone -- `look .` from a pane
-whose directory is gone says `look: <dir>: no such directory`, and a
-`./zz.txt` or `../x` that is not there `look: ./zz.txt: no such file`, while a
-plain `zz.txt` is looked for as text, a miss logged as any look's -- and for a
-Find or Grep that finds nothing, `Grep: pattern not found`; Grep walks
-every pane's directory on this host, passing over panes of the served
-tree (`/virtual/`, a peer's `/n/<name>/`) and directories not there,
-so none of them spoils the rest; Find, Grep and a language server's lists
-(Symbols, Diagnostics, Callers and the rest) share one `+Search` a
-directory, each run replacing what the last showed, as acme reuses a
-directory's `+Errors` (the exec reads that pane back; one that finds
-nothing empties it rather than leave the last rows), while a plain
-word's `LookWord list` search keeps a pane a pattern, ENOSPC for no room or
-slot, EBUSY for a held lock) -- and logs its reason exactly once, as `err <serial|->
-<file>: <why>`, with no `msg` for it. A builtin a click runs (Save, get's
-`Modified`, Tty with no room, Edit) is no exception. The rule is a write's:
-a refused open or truncation -- an OTRUNC open, such as `data`'s after a
-failed `addr` --, create or remove answers its error and
-logs no `err`, and so do a write to `pane/new` (`permission denied`: it is
-only read) and a write on a fid opened OREAD (`bad use of fid`). Every
-refusal is said in words, Plan 9's where pardes has none of its own
-(`permission denied`, `file does not exist`, `bad argument`), never a C
-library string such as `Operation not permitted`. What is not a
-failure: a look that finds nothing answers nothing and logs one `err`
-(`look: no match for ...`, quoting what was written in every form --
-`no match for "zzq:#3"`, `no match for "zzq:2"` -- the same miss again counted, `(x2)`, as any
-repeated `err` is), the write succeeding; and a command line run in
-a command pane ends in its own time, told by its `exit` record.
+- The leftmost match wins, but among alternatives the first that matches,
+ not sam's longest (`/gam|gamma/` finds `gam`). mvzr keeps no
+ submatches, so Edit's `s` has no `\1`-`\9`.
+- A pattern holding `\n` runs over the whole text: there `^` may only come
+ first and `$` only just before a `\n`, else it is refused.
+- An alternation must anchor every branch or none (`^def|^ ` works,
+ `^def|x` is refused).
+- A class may hold non-ASCII runes (`[éa-z]`, a range up to 256 runes); a
+ wider range or a negated class with one (`[^é]`) is refused.
+- At most 512 operations (about 512 characters, counted after that
+ rewriting): `bad regular expression: longer than mvzr's 512 operations
+ (about 512 pattern characters)`.
+- Each search has a step budget (about 300 ms; each search of an Edit `x`
+ its own): `regular expression search took too much time, gave up`. What
+ runs out is exponential backtracking (`a?` twenty times then twenty `a`s)
+ or a quadratic pattern over a very long line.
+
+### Edit
+
+`Edit <sam commands>` on a pane's `ctl` or `exec` (or the root's, at the
+active pane) runs acme's Edit on the body: addresses as above, commands `x y
+g v c a i d s p = m t u` and `{ }`. All changes are one undo step, applied
+only if every command succeeds; a failure changes nothing and fails the write
+with acme's words (`Edit: no substitution`). An `x` that finds nothing
+succeeds silently. `p` and `=` print to the directory's `+Errors`. Not
+there: `b B D e r w f X Y`, `< | >`, `\1`-`\9`. In `s`, `&` is the match
+(`\&` a literal); in `c`, `a`, `i` it is a literal. `y` yields the stretch
+before the first match too.
+
+Braces take a command a line, so a block goes on one open, in one write or
+several:
+
+```sh
+printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $p/ctl
+```
+
+### Flags and undo
+
+`dirty`, `mark` and `scroll` read and take `0` or `1`: the buffer differs
+from its file (a file deleted on disk counts); a write pushes an undo point
+(writing `1` pushes one now); a write scrolls the pane. `/index`'s dirty
+flag is `dirty`; a `+New` scratch reads 1 but holds up nothing under 100
+bytes.
+
+The writes of one open of `data`, `xdata` or `body` are one undo step (so a
+multi-line `printf` is one). To make a loop of opens one step: `echo 1 >
+mark` (a point now), `echo 0 > mark`, the writes, `echo 1 > mark`.
+
+### event
+
+Holding `event` open takes the pane's Look and Exec clicks: they come to the
+reader as records instead of acting, as do lines written to the pane's own
+`look`/`exec` (or the root's while it has the keyboard). A record is acme's
+`<origin><action><q0> <q1> <flag> <n> <text>\n`; read `n` **bytes** of
+text, which may hold newlines.
+
+- origin: `E` a 9P write to body or tag, `F` other files and the editor's
+ own lines, `K` keyboard, `M` mouse.
+- action: `X`/`L` executed/looked in the body, `x`/`l` in the tag (offsets
+ into the whole tag, path included), `I`/`D` body text inserted/deleted,
+ `i`/`d` the tag's.
+- flag: 1 the text's first word is a builtin, 4 (look) a file name or
+ address, 8 (exec) chorded: two records follow, the argument and where it
+ came from. pardes never sends flag 2.
+- A written line, or a click in a terminal's body, has no place: `0 0` with
+ its text (`FX0 0 1 6 Msg hi`).
+
+Write a record back to have it done as the click would: `<o><a><q0> <q1>\n`
+acts on that range; the whole record as read acts on its text (the only way
+for `0 0`). A chorded record with its two follow-ups, in one write or
+three, runs once with its argument. `I`, `D`, `i`, `d` are refused. A helper
+holding `event` that writes its own pane's `exec` gets its command back as a
+record: run it through `ctl` instead.
+
+### REPLs
+
+`Repl python` on a terminal's `ctl` (or in its tag) binds it as that
+language's REPL (names as a code fence spells them: `py`, `sh`, ...); its
+tag and `ctl` line show its id, `python-a`. `Repl -` unbinds, bare `Repl`
+says the binding. A middle click or the execute key on a `.py` body then
+types the text into the REPL instead of running it; builtin words, tag
+words, `Exec <text>` and @`cmd` words still run. With several bound, the
+pane asks (`ask <serial> repl a b`). Bindings are not dumped.
+
+A 9P `exec` is never sent to a REPL. A script either writes the event record
+`MX<q0> <q1>` to the `.py` pane's `event` (sent as the click would be), or
+writes the REPL's `pty/data` itself: multi-line code as a bracketed paste,
+`\e[200~<code>\e[201~`, then `\r` in a separate write once the REPL has
+echoed the paste; a paste of more than one line not ending in a newline needs
+a second `\r`. Line by line, a blank line ends a Python block and Python
+3.14 auto-indents each line.
+
+## Terminals
+
+Terminal panes also have `pty/`:
+
+- `pty/data`: write bytes as typed (`printf 'ls\r'`, `\x03` is Ctrl-C);
+ read the live output stream (a consuming queue shared by readers, not a
+ replay).
+- `pty/status`: one line, `cols rows busy`; busy is 1 while a command runs or
+ text is typed at the prompt.
+- `pty/ctl`: `winsize C R` (at least 2 rows), `sig INT|TERM|HUP|QUIT|KILL`,
+ `exec` (restart the shell in its directory: refused on a command pane, `a
+ command pane does not restart`; `exec: <dir>: no such directory` if it is
+ gone; a shell that cannot start fails and leaves the old one running).
+- `pty/run`: one line at the shell's prompt, answered on the same open:
+
+ ```sh
+ exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3; exec 3<&-
+ ```
+
+ The answer's first line is the header: `exit N` then the command's output
+ (as the screen showed it: no colour, `\r` progress collapsed, trailing
+ blanks dropped; the last 64 KiB, `exit N cut M` when M bytes were left
+ out, bare `cut` when the start scrolled away or was cleared). Or: `busy:
+ <program> is running` (bare `busy` when text is typed at the prompt),
+ `exit ?` (no status reported, not a success), `error not run` (the shell
+ refused the line, e.g. a fish syntax error), `error shell gone`, `error no
+ prompt marks`, and on a command pane `error a command runs here, not a
+ shell` (`error command done; not a shell` once it ended). A line written
+ before a fresh terminal's first prompt waits for it. One line per run;
+ a second line before the answer is refused.
+
+`pty/run` relies on the OSC 133 marks pardes injects into bash and fish;
+`exec zsh` or a continuation prompt never reports an end, so cancel the
+read. A program on the alternate screen (vim, less) leaves no output. A
+program holding the terminal (a REPL, `less`) takes no run: write to
+`pty/data`.
+
+## /log
+
+One 64 KiB ring, one record a line, kept whether or not anyone reads it. An
+open freezes it and reads to EOF. Write `follow` to that open to then wait
+for each new record (`follow new` skips the history, as `tail -n0 -f`);
+`tail -f` never writes `follow`, so it sees nothing new.
+
+```sh
+exec 3<>$m/log; echo follow >&3; while read -r line <&3; do ...; done
+```
-`tag` reads the whole tag as the pane shows it: the computed path or PDF
-page (no mark for unsaved text: the grip shows that, and `dirty` says it),
-then the text you may edit. An image's tag begins with its mode words, not
-its path: `img petscii:off palette:commodore ascii:on <path>` by default, the modes
-it is drawn in, then the file. The editable text is 4096 bytes at most: a
-write that would pass that is refused whole, ENOSPC, `tag: over 4096
-bytes`. A write appends to that text,
-newlines included, and a tag with more than one line takes a row per line on
-screen; truncating `tag` clears it, as acme's `cleartag` does -- the default
-words (`Save Tty Collapse Del` and the rest) with it, since they are that text until
-you edit it, so `echo Make > tag` leaves only `Make` -- a truncating write
-drops the one newline that ends what it wrote, which would draw an empty
-row, and keeps any other; append with `>>` to
-keep them, with `printf ' Make' >> tag`. The leading blank is needed: the
-tag reads back with no blank after its last word, so `printf Make >> tag`
-glues `Make` onto it; and `echo ' Make' >> tag` ends with a newline, which
-starts a new line of the tag. The clearing is an edit of the tag like a typed one and its undo
-history is kept: `u` in the tag brings back the text it cleared, words
-included.
+A follower the ring outran reads `lost N` first. A Restore hangs the
+follower up: dial again and read from `restore <path>`.
-Stats report real lengths for `index`, `status`, `look`, `exec`, `listeners`,
-`name`, `body`, `tag`, `sel`, `ctl`, the range files and the flag files, and
-for `event` and `pty/data` the length of the record a read would
-answer, which is zero when nothing is waiting; for `log`, the text an open
-would freeze now. Modes are 0644/0666 (0444 for
-read-only files, 0222 for write-only); mtime is the pane's last edit or the
-process start. The qid version of `body`, `data` and `xdata` is the pane's
-revision, so a stat sees an edit land without reading the text; every other
-file leaves it zero rather than promise a version it cannot keep. Directory
-entries carry no sizes, and neither does `/screen`, which has no length until
-an open renders its frame; stat the entry.
+| record | when |
+|---|---|
+| `new <serial> <name>`, `del`, `rename`, `save` | a pane made, closed, renamed (a terminal's too, as its shell changes directory), saved |
+| `newcol <serial>`, `delcol <serial>` | a column made or closed |
+| `msg <serial\|-> <text>` | the editor said something (not a builtin's own name under `Verbose`) |
+| `err <serial\|-> <file>: <why>` | a write was refused or failed |
+| `run <serial> <line>`, `exit <serial> <N\|?>` | a command pane's command started and ended; also a terminal whose shell exited, before its `del` |
+| `send <from> <to> <repl-id>` | text went to a REPL |
+| `ask <serial> <what> <choices>`, `answer <serial> <choice\|->` | a pane asked, and was answered (`-`: taken back, or the pane closed) |
+| `changed <serial> [reloaded\|deleted]` | its file changed on disk (bare: under unsaved edits) |
+| `unsaved <serial> <name>` | a pane an Exit, Restore, Del or Delcol refused over, before the `err` |
+| `dump <path>`, `restore <path>` | a Dump written; a Restore, after its panes' `new`s |
+| `restored <old> <new>`, `restoredcol <old> <new>` | serial maps after a Restore |
-`/log` is one ring (64 KiB) that records whether or not anyone reads it:
-`new`, `del`, `rename` (a terminal's too, as its shell changes directory --
-a new terminal is `new N <dir>`, named by the directory it is started in,
-and renamed only when its shell goes elsewhere (the boot's, started with
-no directory given, is `new N /` until its shell says where it is); it
-runs `ls` once, at its shell's first prompt only, never later over what
-someone ran or typed first, a greeting that shows the directory it opened
-in, since a terminal is named by its directory and nothing else on it says
-where it is), `exit <serial> <N>` before
-the `del` of a terminal whose shell exited by itself, `ask <serial> <what>
-<choices>` when a pane asks a question (answered by `answer` on its ctl;
-`ask <serial> save path` when a Save made over 9P, on a terminal or a
-scratch, opens its prompt for a path, answered `answer <path>` or `answer
--`; asked again, the one open stands, not a second),
-`answer <serial> <choice|->` when it is answered, by key or ctl, `-` for
-taken back or for its pane closing with the question standing,
-`changed <serial>` when a pane's file changed on disk under its unsaved
-edits (below), `changed <serial> reloaded` when a pane with none reloaded
-it, and `changed <serial> deleted` when it was deleted or moved away, `unsaved <serial> <name>` for each pane an Exit, Restore,
-Del or Delcol refuses over (before that write's `err`), and `save <serial> <name>`,
-`dump <path>` when a Dump is written and `restore <path>` in a Restore's
-new log after its panes' `new`s (a relative Restore path is looked for in
-`DumpDir`, then in the directory pardes started in; a bare Restore takes the
-last dump this session wrote), then `restored <old> <new>` for each pane,
-mapping the serial it had to the one it has now, and `restoredcol <old>
-<new>` for each column, and `msg <serial|-> <text>`
-for every line the editor says (a builtin announcing its own name as it
-runs, with `Verbose` on, is the message row's alone, never logged: a `msg`
-is something said, `Kill: nothing running`). A builtin that
-fails its write (`ctl`, `exec`, `/tagexec` or a column's `exec`, and an
-open of `pane/new`) is logged by that write's `err` alone, no `msg`, so
-the same failure again is the same record again; a line said again word for word is counted, `msg 3 Undo: nothing to
-undo (x40)`, as `err` is (below); a text past 256 bytes is cut there,
-between words, and ends in an ellipsis, `…`, as an `err`'s reason is past
-200; its serial is the pane it ran at -- a line written to the root's
-`exec` or `look` runs at the active pane, and is logged as that pane's,
-even while the keyboard is on a column's or the workspace's tag -- and `-`
-for a line to the root's `ctl`, `/tagexec`, or a column's `ctl` or `exec`,
-which run in a tag no pane owns), and `err <serial|->
-<file>: <why>` (the serial is that of the pane whose file it is, and for
-the root's `exec` and `look` the pane the line ran at, `err 3 exec: ...`;
-`-` for another root file) for every write or truncation the tree refused or that
-failed -- through a mount a shell sees only the errno its kernel mapped the
-reply to, usually `Invalid argument`, and this is the reason (`err 3 addr:
-no match for regexp`). A record said again word for word, straight after
-itself, is counted rather than repeated (`err 3 addr: no match for regexp
-(x4)`: four in all, counting the first), so a client retrying a failing
-write does not push the rest out of the ring. A record a follower has
-already read is never rewritten: the next repeat is a line of its own
-carrying the running total, `(x5)`, and counting goes on from there, so a
-follower sees each count as a new line: with a follower attached, a repeat
-is a new line carrying its `(xN)` count, never an edit of the one read. Through a kernel mount a client sees only an errno, which 9ns reads from the
-error's words (cloud9's 9ns/src/nine.zig, `enameToErrno`): a malformed write
--- an unknown or ill-formed control message, `bad address syntax`, `bad
-regular expression` -- is EINVAL; a lock another open holds, EBUSY; a pane
-gone, ENOENT; a well-formed write that fails -- `no match for regexp`,
-`address out of range`, `addresses out of order`, a search that gave up,
-`<name>: Modified (Exit again to discard)` -- EIO. The err record has the
-words. There is no per-pane error file to read instead:
-acme's `errors` only takes text, and one record stream is simpler to watch
-than a file per pane. A `msg` said while a
-pane is being made can precede that pane's `new`; panes present at boot are
-recorded before anything else.
-Every EINVAL a write gets says why, in its reply and in its `err` record
-(`bad character in file name: an empty name`, `invalid write to log: it
-takes \`follow\` or \`follow new\``, `invalid write: this pane has no
-text`), never a bare `Invalid argument`.
-Control characters, DEL and C1 controls (U+0080-U+009F) in a record become
-spaces, and a byte that is not UTF-8 is written `\xNN`, so a record is one
-line of UTF-8; a pane's name, in a record, in `/index` and in a terminal's
-tag alike, shows a newline in it as `\n` rather than a space and its own
-backslash as `\\`, so a directory named with a newline and one named
-with a backslash and an `n` read back differently, as what they are.
-(An `event` record is not: acme's `<origin><action><q0> <q1> <flag> <n>
-<text>\n`, whose text may hold newlines. The origin is `E` (a 9P write to body or tag), `F` (other
-files, the editor's own lines), `K` (the keyboard) or `M` (the mouse); the
-action's case says where: `x`/`l` a click executed or looked at in the tag
-(its offsets count the tag's whole text, path included, as `tag` reads),
-`X`/`L` in the body, `I`/`D` text put in or taken out of the body, `i`/`d`
-of the tag. The flag is acme's: 1 the text's first word is a builtin's, 2
-an expansion record follows (acme's look.c:42-43, exec.c:154-157; pardes
-never sends one: a click's record already carries the word it took, its
-range and text, so 2 is never set, whatever the origin), 4 (a look) the
-text is a file name or address, 8 (an exec) chorded: two records
-follow, the argument's text and where it came from, `<file>:#q0,#q1`, each
-with no place of its own: offsets `0 0` and flag 0, `Mx0 0 0 5 hello` (as
-acme's exec.c:182 writes them).
-Written back, an `X`/`x` record executes and an `L`/`l` record looks, as
-the click would have. A chorded one (flag 8) runs with its argument: the
-record after it in the same write, else the one kept from the click; its
-two follow-up records written back after it, together or in writes of
-their own, are consumed as its, never run as commands (written back alone
-with no argument kept, it waits for its argument record); the origin letter
-written back is ignored but for `F` on `X`, which runs as the command it
-was rather than going to a bound REPL; `I`, `D`, `i` and `d` are reports and
-are refused. Read `n` bytes of the text, not up to a newline -- bytes here, where acme counts runes. Every offset and
-count pardes serves is in bytes, `#n` and `q0`/`q1` too; the event count
-follows them rather than switch alone, so an acme library reads pardes
-correctly for ASCII text and not beyond it. Offsets are bytes, but every
-address lands on a rune boundary, as sam's and acme's work in runes, never
-inside a multibyte rune and never widened to a grapheme cluster: a `#n`
-inside a rune snaps back to its start, a `line:col` likewise, a search's
-match covers the runes it touches, and a copy of addr to dot keeps its
-runes, so a lone combining mark or the `\r` of a CRLF is addressable on its
-own (an Edit's `x`, `y` and `s` match the same way: `.` is one rune); `addr` reads back the snapped offset. (How the terminal draws such a
-text, a cluster to a cell, is apart from this.) A click in a
-tag gives offsets into the whole tag as `tag` reads it, the path first.) An
-open freezes the ring's text the way `/screen` freezes a frame: reads walk it
-and end. Writing `follow` to that same open makes reads past it wait for the
-next record, one per read, after first reading all that the open froze (the
-ring's whole history, up to 64 KiB); `follow new` skips that and waits for
-what comes after, as `tail -n0 -f` does. A follower the ring outran reads
-`lost N` first.
-Closing the open is the only way back, as with rio's `consctl`. A follower
-misses nothing within a session only: a Restore hangs its connection up
-(and a 9ns mount with it, which must be restarted), so it dials again and
-reads the new log from its `restore <path>`.
+The serial is the pane the line ran at (the active pane for the root's
+`look`/`exec`), `-` for the root `ctl`, `/tagexec` and column files. A
+record said again straight after itself is counted, `err 3 addr: no match
+for regexp (x4)`; a follower sees each count as a new line. A `msg` is cut
+at 256 bytes and an `err` reason at 200, ending in `…`. Control characters
+become spaces.
-A read with nothing to give yet -- a following `log`, `event`, `pty/data`, a
-`pty/run` before its answer -- is held, the way factotum holds its log's reads
-(security/auth/factotum/log.c) and acme an event read: the core keeps it, and
-whoever next has news for it (a record, output, a run's answer, the pane
-closing, which answers `no such pane: its window shut`, ENOENT as any
-other file of a gone pane gives, where acme says "window shut down") answers it on the
-connection it came on as the turn is given up. Nothing else parked is
-retried for it. The core keeps the ticket cloud9 gave the park
-(`Conn.hold`) and answers only while that very park waits, so a read the
-client flushed or whose fid it clunked meanwhile is dropped unanswered, no
-record is spent on it, and a tag the client reuses is never answered with
-what was meant for the old one. An open waits with one read at a time, as
-acme's window keeps one `eventx`; a second read on it meanwhile fails with
-"file in use". QUIC connections still retry their parked reads each tick.
+## Other files
-`pty/run` runs one line at a terminal's prompt and answers how it ended, on
-the same open (factotum's `rpc` shape): write the line, then read `exit N`
-once the command has ended and the shell is back at a prompt, followed by
-what it printed. One line a run: two lines in one write, or a next line on
-the open before the last answer is read (bash writes `printf 'a\nb\n'` a
-line at a time), are refused, EINVAL. The output is what the screen showed between the command's
-start and end marks: stderr interleaved, `\r` progress collapsed to its last
-state, no colour, tabs as the spaces they drew, trailing spaces trimmed and
-trailing blank lines dropped (`printf 'a\n\n\n'` answers `a`), leading
-whitespace kept; a program on the alternate screen (vim, less, htop) leaves
-none, and rows a program redrew above its start are missed. A bash job notice
-printed before its PROMPT_COMMAND lands in the next run's output. Only its
-last 64 KiB are kept, from a line start, and the header then reads `exit N
-cut M` (M bytes left out). `exit N cut`, with no count, says the output's
-start is not there to read: it scrolled out of the history, the command
-erased the screen (`clear`, `watch`, a full-screen program's redraw, a
-reset -- so such a command's output may read as cut), a start or end mark
-came on the alternate
-screen, or the command printed more than 8192 rows, of which only the last
-are read so that the answer costs the editor a bounded amount. `exit ?` is a
-command whose end mark carried no status, which is not a success; `error out
-of memory` is an answer that could not be made. The header is always the
-whole first line. It reads
-`busy: <program> is running` at once when a command is running (bare `busy`
-where the host cannot name the program, and when text is typed at the prompt)
--- a run is a line typed at the shell's prompt, so a program holding the
-terminal (a REPL, `less`) takes none: write to `pty/data` for it --
-which is also when the third field of `pty/status` reads 1. `pty/status` is
-one line, three right-aligned fields and a newline: the pty's columns and
-rows, then busy (0 or 1). `pty/ctl` takes `winsize C R`, `sig
-INT|TERM|HUP|QUIT|KILL` and `exec`, which starts the pane's shell again in
-its directory (a command pane's child is its command, which does not
-restart: `exec` there is refused, EINVAL, `a command pane does not
-restart`): one that is gone is refused before anything runs, `exec:
-<dir>: no such directory` (ENOENT), and a shell the host cannot start (not
-there, not executable, a script whose interpreter is not there) fails the
-write with why -- `shell: shell not found`, or `shell: interpreter
-/no/such/interp not found` for a script whose `#!` names a program that is
-not there, ENOENT -- keeping the terminal and its running shell: a shell
-not there is refused before anything runs, and the host starts the new one
-before the old goes, so one that cannot start leaves the old be; a `Tty` naming such a shell or
-script is refused before anything runs (`Tty: interpreter ... not found`,
-its `err` the only record), and one whose shell cannot start fails the
-same way and leaves no pane. The host knows before it answers: the child reports a failed exec
-through a close-on-exec pipe. A directory removed
-under a running shell leaves the pane its name (never `... (deleted)`), so
-an `exec` works there once the directory is back. The size, and `pty/ctl`'s `winsize` read back, is
-what `winsize C R` last set (R of 1 is taken as 2: a one-row pty loses
-its prompt's mark and would read busy for ever), until the pane itself
-resizes and gives the pty its grid again. A line written
-before a new terminal's shell has drawn its first prompt is not busy: it
-waits for that prompt (a respawn meanwhile keeps it waiting for the new
-shell's) and is sent then, so the first command a script gives a fresh
-terminal is not lost. A shell that never draws a tagged prompt (one pardes
-could not instrument, or a startup that hangs) leaves such a line waiting
-for ever: cancel the read (interrupt it, or close the open) to give up;
-`error not run` when the shell refused
-the line without running it (a fish syntax error; the line is taken back off
-the prompt): the shell's marks say only that it did not run, so the answer
-carries no reason or code, and the shell's own complaint is in the pane's
-body (`tail body`); `exit N` and what it printed when the line ended the shell
-itself (`exit 3`, or `echo bye; exit 3`): its terminal closes, and a read
-of the run's open still answers after the pane is gone; `error shell gone`
-when the pane closed or its shell was replaced, the shell went without
-an exit status to tell, or it never started (a `Tty` in a directory that is
-not there is refused before, `Tty: <dir>: no such directory`, and makes no
-pane); `error no prompt marks` for a shell pardes could not instrument;
-`error command done; not a shell` (or `error a command runs here, not a
-shell`) on a command pane, whose child is its command.
-It relies on the OSC 133 marks pardes injects into bash and fish, tagged
-`aid=pardes` so fish's own marks and a nested shell's are ignored. A second
-line on an open whose command still runs fails the write. `exec zsh` or a
-continuation prompt never reports an end; cancel the read. A read of `run`
-waits in pardes, so through 9ns it needs 9ns's concurrent requests or it
-holds up the rest of the mount. A record
-longer than a read comes in pieces, so a shell's `read` loop works: `exec
-3<>$m/log; echo follow >&3; while read -r line <&3; do ...; done`. bash's
-`read` takes a chunk, keeps one line and seeks back to just past it; a
-followed log, `event` and `pty/data` answer a read at an offset inside
-their last answer from that answer again, so no record is lost. Through a
-FUSE mount (9ns --mntgen) bash's `read -t` does not time out on a followed
-file: the read waits in the mount, where its timer cannot cut it; wrap the
-loop in `timeout N`, or read with `cat` in the background and poll what it
-wrote. `tail -f`
-never writes `follow`, so it sees nothing new: use the follow open instead. `/screen` returns JSON with
-`cols`, `rows`, `cursor`, a `styles` table, and row-major `cells` of
-`[grapheme, style_index]`. Each open freezes one frame until close. A
-terminal `body` freezes its history on the first read of each open handle;
-a PDF's `body` reads the text layer of the page it shows (MuPDF's
-extraction; turn the page for another), and it, as an image's, takes no
-write (`this pane has no text`, EINVAL);
-`pty/data` streams live output. The listings -- `/index`, `/layout`,
-`/recent`, `/commands`, `/status`, `/listeners`, and a `ctl` opened only to
-read -- freeze at the open too, so one read in several chunks never splices
-two moments; open again for what is there now. An open that holds something between open
-and close -- a frozen screen, listing, terminal body or log, a run, an `event` or
-`pty/data` open -- takes one of 64 records (lib9p's per-fid aux, acme's
-Fid), released on close or disconnect; past that such an open fails with
-`ENFILE`. Other opens hold nothing and are not counted.
+- `/screen`: JSON `cols`, `rows`, `cursor`, `styles`, and row-major `cells`
+ of `[grapheme, style_index]`; one frame per open. Compare a cell's style
+ through `styles`, not the index.
+- `/commands`: `Word [arg] root|pane|both [values] -- sentence`, one a line,
+ generated from the builtin registry (`both`: Edit, at the active pane from
+ the root).
+- `/recent`: up to 200 files, most recent first, `open <path>` or `closed
+ <path>`; kept in `$XDG_STATE_HOME/pardes/recent`. `Recent` shows them in a
+ pane; a look at a row reopens the file at its last dot.
+- `/status`: `pid`, `version`, `panes`.
+- `/os/`: existing regular files take read, write and truncation to zero;
+ create, remove, rename and metadata changes are refused; ownership is
+ synthetic. Linux v9fs's truncation `mtime` hint is accepted and dropped.
+- `/src/` (and `/shaders` on GUI builds) with `-Dembed-sources=true`;
+ `EffectCode <effect>` lists an effect's files under `/virtual`.
-`-Dembed-sources=true` embeds the editor's sources and serves them under
-`/src` (and `/shaders` on GUI builds). `EffectCode <effect>` lists the current
-backend's implementation files under `/virtual`, which Look opens; without the
-option the command reports the sources as unavailable. It is off by default
-everywhere; the esp32p4 image in particular has no room for them (~1.8 MB of
-source against a 1.5 MiB app partition).
+## Limits
-This is a control filesystem, not a complete POSIX export. Native filenames
-may contain up to 255 bytes. Existing regular OS files support read, write,
-and truncation to zero; under `/os` protocol create, remove, rename and other
-metadata changes are refused, as is every create in the control tree and every
-remove in it but a pane directory's. Ownership and permissions under `/os` are
-synthetic.
-Zero-length truncation accepts the accompanying `mtime` hint sent by Linux
-v9fs; the hint is not stored. Standalone timestamp changes remain refused.
+| | |
+|---|---|
+| panes | 64 (16 on the board) |
+| columns | 16 (6 on the board), each at least 10 cells wide |
+| rows a pane keeps | its tag and 2 |
+| msize | 8192 offered |
+| connections | 16 Unix+TCP, 16 QUIC |
+| opens holding state | 64 |
+| named mounts | 8 |
+| command line | 1024 bytes; a held line or Edit block 1 MiB |
+| tag text | 4096 bytes |
+| regular expression | 512 operations; a step budget per search (~300 ms) |
+| undo | 256 steps |
+| log | 64 KiB ring; `msg` 256 bytes, `err` reason 200 |
+| `pty/run` output | 64 KiB |
+| file name component | 255 bytes |
-The tree lives in `src/ninep/`: `tree.zig` (nodes, lookup, readdir, dispatch,
-and the editor's reply payload over cloud9's backend contract), `pane.zig`
-(pane files), `ctl.zig`, `addr.zig`, `pty.zig`, `events.zig` (event and log
-streams), `screen.zig` and `sources.zig`. The protocol engine is cloud9's
-`fs.Server`, configured in `src/9p.zig` (the editor's and the board's
-capacities); the transports are `src/9p_io.zig` (cloud9's `serve.Runner`
-for Unix and TCP, a poll loop for QUIC, and the 9P client for mounts);
-`src/fs.zig` keeps host access, mounts, resolution, find and grep.
+## Source and tests
-`zig build fs-test` drives real sessions using the independent Python client
-in `test/ninep.py`; `zig build fs-discovery-test` checks that browsing
-creates nothing, that an open of `/pane/new` and a remove work, and that
-`look`, `exec`, `name`, `sel` and `log` behave. `zig build
-9p-test` checks the two engine configurations' budgets (the engine's own
-tests are cloud9's `zig build test`); `zig build fs-bench` measures
-filesystem transactions in the core.
-`zig build fs-test quic-test -Dquic=true` also exercises QUIC mounts and I/O.
+`src/ninep/`: `tree.zig` (nodes, dispatch), `pane.zig`, `ctl.zig`,
+`cols.zig`, `addr.zig`, `pty.zig`, `events.zig` (event and log), `screen.zig`,
+`sources.zig`. The engine is cloud9's `fs.Server` (`src/9p.zig`); transports
+are in `src/9p_io.zig`; `src/fs.zig` holds host access, mounts and
+resolution. `zig build fs-test` drives real sessions with `test/ninep.py`;
+`zig build 9p-test` checks the engine budgets.
diff --git a/docs/open-questions.md b/docs/open-questions.md
index d4b5699c..3f365ce9 100644
--- a/docs/open-questions.md
+++ b/docs/open-questions.md
@@ -1,76 +1,17 @@
# Open questions
Decisions deliberately left open, with what is known so far, so they can be
-picked up without redoing the research.
+picked up without redoing the research. None is open now.
-## Where does an unknown command word run?
+## Decided
-Raised 2026-09-27 in the review of pardes against acme (Plan 9 source at
-`~/05-genizah/principia-softwarica`).
-
-**Decided 2026-09-28: a command pane.** Clicked or written at an interactive
-terminal at its prompt, the line is typed into that shell, whose state the
-clicker can see. From anywhere else it runs as its own terminal pane whose
-child is the root ctl's `Shell` run with `-c` and the line, in the pane's
-directory: full emulation
-(colours, `less`, `vim`, `sudo`'s prompt work), the exit status from waiting
-on the child (`exit N`, `exit 127` for a misspelling, no prompt marks
-needed), Kill signalling its process group, `run`/`exit` records in the log,
-and `exec` answering its serial. One command pane per directory is reused
-once done, and keeps what it showed, as acme appends to `+Errors` and never
-clears it (util.c:213-221). Chosen over acme's process-into-`+Errors` because
-it keeps interactive programs working without a streaming runner, and over
-a PATH check before typing into a shell, which misjudges builtins, aliases
-and functions and leaves the shell's state in the way. The analysis below is
-what it was decided from.
-
-**Today.** A middle-click, an `exec` write or a tag word that is not a builtin
-is typed into a terminal pane: the pane itself when it takes a command line,
-else a shell for the pane's directory (`execute`, `ttyForDir` in
-`src/exec.zig`). The command shares that shell's cwd, environment, history
-and aliases, and its output lands in the terminal.
-
-**acme.** An external command runs as its own process: stdin from
-`/dev/null`, stdout and stderr to the directory's `+Errors` window, `$winid`
-and `%` set for it (`editors/acme/exec.c`, `run()` and its callers around
-:1180-1300 and :1425). No shell state is shared between commands.
-
-**What each costs.**
-
-* Typing into a shell: interactive programs work, and so do aliases and
- functions. But every command depends on that shell's state (is it busy,
- which directory it is in, what was typed at its prompt), and a misspelled
- builtin silently becomes a shell command. The 9P `exec` file cannot report
- that the command failed.
-* A process per command: the misspelling hazard goes away at the root, the
- editor knows each command's exit status and output, and `exec` could answer
- the way `pty/run` does. But aliases, shell functions and interactive
- programs need a terminal of their own, and output goes to a `+Errors`-style
- buffer rather than a live terminal.
-
-**Evidence from use (2026-09-28).** A fresh agent driving pardes through
-9P wrote a misspelled word to `exec`; it was typed into a terminal's fish
-shell ("Unknown command"), nothing reached `log`, and the write succeeded.
-Its report ranked this among the confusing behaviours.
-
-**Related.** `pty/run` (see `docs/fs.md`) already gives an agent the second
-behaviour inside a chosen terminal: a line in, `exit N` and its output out.
-The planned root `ctl` (session builtins) and pane `ctl` (pane builtins)
-refuse unknown words, whatever is decided here; the question is only what
-`exec` and the middle-click do with them.
-
-## Where does an exec from a code file go?
-
-**Decided 2026-09-28: to a REPL bound for its language, when one is.**
-`Repl python` binds a terminal; then an exec made by a gesture (a middle
-click, the execute key, a selection or a single word) on the body of a file
-in that language, or on the REPL's own body, is typed into the REPL
-instead of run. What stays a command whatever is bound: a builtin's word,
-a word in a tag, `Exec <text>` run by name, a command word @`cmd` in the
-text, and a 9P `exec` write (a script writes the REPL's `pty/data`). An
-event record written back is done as the click it was, REPL and all. With
-several bound the pane asks which, one key answering, and remembers
-nothing. A REPL takes text only while its program has the terminal, and a
-done command pane cannot be bound. Chosen over a per-file or per-directory
-binding, which would need a place to keep and show it; bindings are not
-dumped. `docs/fs.md` has the behaviour.
+- **Where an unknown command word runs** (2026-09-28): at a terminal idle at
+ its prompt it is typed into that shell; anywhere else it runs as a command
+ pane, a terminal whose child is `Shell -c <line>`, reporting `exit N` in
+ its tag and the log ([fs.md](fs.md#look-and-exec)). Chosen over acme's
+ process writing into `+Errors` because interactive programs keep working
+ without a streaming runner, and over checking `PATH` before typing into a
+ shell, which misjudges builtins, aliases and functions.
+- **Where an exec from a code file goes** (2026-09-28): to a REPL bound for
+ its language, when one is ([fs.md](fs.md#repls)). Chosen over a per-file
+ or per-directory binding, which would need a place to keep and show it.
diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md
index f39504c2..6e1cd455 100644
--- a/docs/render-pipeline.md
+++ b/docs/render-pipeline.md
@@ -505,7 +505,7 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`,
(file_watch shader slots): a change there has each file read and hashed,
and one whose bytes moved compiles again; the same bytes (good or bad)
compile nothing and say nothing twice. The process that holds the core
- compiles (shader_build.zig): a local GUI, or a detached session, which
+ compiles (ShaderBuild.zig): a local GUI, or a detached session, which
sends its attached GUIs the chain with each file's SPIR-V (wire `post`,
on attach and on every change), so an attached GUI runs the same passes,
levels and ShaderAnimation as a local one and still reads no disk and runs
diff --git a/docs/tags.md b/docs/tags.md
index 7671e056..8fe63804 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -1,313 +1,161 @@
-# Editable tags
+# Tags and columns
-Pardes has three levels of command text: the workspace tag, one tag per
-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.
-A command or a `Tty` run from the workspace tag or a column tag starts in
-the session's directory, where pardes was started, as acme's row and column
-tags have no directory of their own; one run from a pane's tag or text
-starts in that pane's directory.
-`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.
-In a terminal's own tag the word names the shell it runs, `Tty+fish`, tinted
-with the tag's name colour: `Tty+arg` is one word a tag can hold for `Tty
-arg`, so clicking it opens another terminal on that shell, as `Tty fish`
-would. Only a builtin that declares `plus_arg` reads a `+` so; `Tty` is the
-one, since other arguments (a dump's name) may contain `+`. A command pane's tag reads `<dir> (<line>) running` or `exit N`
-and offers `Kill`. `Repl python` in a terminal's tag binds it as Python's
-REPL, its id, `python-a`, beside the Tty word: a middle click or the execute
-key on a `.py` body then types the text into it rather than running it, and
-several bound ask which. `Repl` takes any language a code fence names
-(`py`, `Python`, `sh`): ada, bash, c, c_sharp, clojure, cpp, css, elixir,
-erlang, fortran, go, haskell, html, java, javascript, json, kotlin, ocaml,
-markdown, pascal, php, powershell, python, ruby, rust, scala, typst, zig;
-an unknown one is refused, naming a few of these. The tag's own words, `Exec <text>` by name and a
-command word @`cmd` still run as commands (docs/fs.md).
-A tag word runs as acme's does, in the pane's directory with no file named:
-`wc` alone waits on its stdin. pardes has no `$%` for the pane's file; name
-it (`wc notes.txt`), or select the name and middle-click `wc`, which takes a
-held selection as its argument.
-Pane and column command text leave a small gap after their aligned drag grips.
-GUI pane mode symbols are centered by their visible ink; changing the font or
-its size refreshes the cached measurements.
-The column grip has a different
-color from a pane grip. It stays blank and muted even when its column is active,
-and lights in its full accent while it is held.
-Drag it past a neighbour's middle to move the whole column there; dropped short
-of that, it moves the column's left edge instead, as a border drag would. While
-it is held, a dashed rail in the grip's accent stands where the column's left
-edge will land, beside the rule as a border drag's rail does. Only columns
-between the old and new positions shift. Each column keeps its width, panes and
-command text.
-
-Two panes in a column are resized as acme's windows are, by the grip: drag
-a pane's grip up or down its own column and its top follows, the pane above
-taking or giving the rows, either one down to its tag alone (acme's
-coldragwin keeps a window its tag line); drop it in another column and the
-pane moves there. In the GUI the 2 px rule between two panes also drags
-their seam. No row of text is a handle: a pane's last row takes a click like
-any other, and a terminal, which draws no rule, resizes by the grip. A
-column's right edge still drags its width.
-
-When Look opens the first document and its source column is too narrow for
-a new column, it splits below the originating pane, just like `Tty`, instead
-of inserting at the top of the leftmost column. The originating terminal is
-kept. As with `Tty`, a tagline-only source uses a roomier split parent.
-
-`Collapse` reduces a pane to just its tagline, giving all its body space to
-the nearest expanded pane above, or below if none is above. Other panes keep
-their heights. Execute it again to reclaim its former height from that one
-neighbor, as far as available space allows. If every pane is collapsed, the
-unused area stays blank.
-Its text and terminal process are kept;
-the command stays available in the visible tag. Every default pane tag includes
-`Collapse`, including file, terminal, image, and PDF panes.
-
-`Edit` is acme's: the rest of the line is sam's command language, run on
-the pane's body (`Edit ,x/foo/c/bar/` renames every `foo`), from a tag, a
-pane's `ctl` or `exec`. Addresses are the `addr` file's, `line:col`
-included; the commands are `x y g v c a i d s p = m t`, `u` alone, and `{ }`
-with a command per line. All its changes are one undo step, applied only
-if every command ran; an error says why in acme's words and changes
-nothing. `p` and `=` print to the directory's `+Errors`. Left out: the file
-commands `b B D e r w f X Y`, the pipes `< | >`, and `\1`-`\9` in `s`,
-since mvzr keeps no submatches; acme applies changes that come out of
-sequence with a warning, pardes refuses the Edit. In `s`, `&` in the
-replacement is the matched text (`\&` a plain `&`); in `c`, `a` and `i` an
-`&` is only an `&`, as in sam. A pattern that finds nothing says so with the
-pattern (`no match for regexp /nomatch/`), and an `s` on an empty dot says
-`no substitution: dot is empty`. A loop that finds nothing is no error, as
-in sam: `Edit ,x/zzz/c/bar/` with no `zzz` changes nothing and succeeds,
-silent, where `,s/zzz/bar/` says `no substitution`.
-
-`Undo` and `Redo` are acme's: typed or clicked in a pane's tag, or written
-to its `ctl`, they step the body back and forward through its edits, as the
-`u` and `U` keys do. They are not in the default tags.
+Pardes has three levels of command text: the workspace tag, a tag per
+column, and each pane's tag. A column tag's words act on that column and run
+in its active pane (its first pane when focus comes from another column). A
+command or `Tty` run from the workspace or a column tag starts in the
+session's directory (where pardes started), as acme's row and column tags
+have none of their own; one run from a pane's tag or text starts in that
+pane's directory. Over 9P the tags are `/tag`, `/col/<n>/tag` and
+`/pane/<n>/tag` ([fs.md](fs.md#columns-and-tags)).
-`Del` closes a pane and gives its rows to one neighbor; the rest of the
-column keeps its heights. A pane with unsaved text is refused once, as
-acme's Del warns (exec.c del, wind.c winclean): a short notice, `1 unsaved
-pane — Del again to discard`, the pane listed in a kept `+Unsaved` (`<name>:
-Modified`, then `Del again to discard`), and the same `Del` again, nothing edited since, closes it and
-throws the text away; `Delcol` refuses a column holding such a pane the same
-way (exec.c delcol), but changes nothing else in refusing -- no `+Unsaved`
-opens, no focus moves; its `unsaved` records and notice say which. Each word warns on its own, from a key, a tag, `exec`
-or a ctl, where the refusal fails the write with EIO. `Del k` (or `DelAbove`) gives them to the nearest
-expanded pane above, `Del j` (or `DelBelow`) to the one below, each falling
-back to the other side. Bare `Del` from the keyboard (`SPC d`, or Enter on
-the word) on a pane with expanded panes both above and below asks on the
-pane's notice band: `k` or Up gives the rows above, `j` or Down below, and
-any other key keeps the pane. A click, a 9P write or a startup line never
-asks; there bare `Del` gives the rows to the nearest document above, as it
-always has. A collapsed pane is never asked about, and collapsed neighbors
-are passed over: they only keep a weight for later.
+## Default words
-A tag is a text like a pane's body: pane tags, column tags and the
-workspace tag all edit with the body's own normal and insert modes, undo
-included. A pane's tag may hold more than one line, and a line wider than
-the pane wraps onto more rows, as acme's tag does: it takes a row per shown
-line, up to eight, and leaves its body at least one row. A line exactly as
-wide as the pane stays one row, its caret at the tag's edge past the last
-character, as acme's tick is (in a terminal, on the last cell). A word the
-wrap breaks across rows is still one word to a click. A collapsed pane shows
-only the first row.
+- Workspace: `Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor
+ Debug Exit`.
+- Column: `New Tty Find Grep Joincol Delcol`.
+- File pane: `Save Tty Collapse Del`; a source file with a grammar adds
+ `TreeContext`, a result list `LocationsConfig`. PDF: `manual.pdf [1/12]
+ Tty Del PdfSections PdfTint Collapse`.
+- Terminal: `Tty+bash Save Mode Filter Collapse Del`. `Tty+bash` is one word
+ for `Tty bash`, opening another terminal on that shell (only `Tty` reads a
+ `+` so). `Mode` cycles raw terminal input, normal mode and insert mode.
+- Command pane: `<dir> (<line>) running`, then `exit N`, and `Kill`.
-A pane tag starts expanded, showing all its rows, and can be collapsed to
-one row, as acme's Tagup does. In the tag's insert mode Up on its first row
-collapses it and Down on its last row expands it; Alt-Up and Alt-Down do
-the same in either mode. acme expands or collapses on any arrow key typed in
-the tag (Up collapses, the others expand); here arrows keep moving the
-cursor, and only these keys at the tag's edges change its height. The column and workspace tags
-are one line, as acme's (cols.c:244 gives a column tag one font height): a
-newline typed, pasted or written into one becomes a space, and a dump that
-holds a column or workspace tag of several lines, from before, comes back
-with its lines joined by spaces. The path and a PDF's page at the start of a
-pane tag are computed, never stored, and read-only. The keyboard reaches
-them as the mouse does: `0` goes to the line's start, the path's, as in
-acme, and motions select and yank across the path and the commands. An edit
-that would change them is refused and leaves the cursor where it was; typing
-into a file's path instead drafts a new name, as clicking it does. When they
-grow or shrink (a rename, a PDF's page) the cursor keeps
-its place in the text after them.
+`Undo`, `Redo` and `Mode` (on files) work typed or clicked though they are
+not in the default tags. Customized tags keep their text.
-Left-click a tag or a header to type into it at the click, in insert mode.
-A pane tag wraps instead of scrolling sideways; a column or workspace tag
-reveals the caret horizontally when its text is wider than its column.
-Esc is normal mode, where everything a body's normal mode does works. There
-Tab executes the word under the cursor (or an explicit selection), and Enter
-looks it up in a pane's tag but runs it in a column or workspace tag, whose
-words are all commands; in insert mode Enter is a new line. Executing gives
-the keyboard back to the body first, so `Del`, `Kill` and `Restore` never return into a tag that is gone.
-Pasted text goes to the focused tag, not to the file or embedded shell
-beneath it.
+A tag word runs as acme's does, in the pane's directory with no file named:
+`wc` alone waits on stdin. There is no `$%`; name the file (`wc notes.txt`),
+or select the name and middle-click `wc`, which takes a held selection as its
+argument.
-`:` is the one key a tag and a body do not share: in the body's normal mode
-it focuses the pane's tag in normal mode, and in the tag's normal mode it
-goes back to the body; in a column or workspace tag it goes back to the
-active pane. Each pane, column and workspace tag remembers its own cursor
-during the session; the first `:` into a pane tag starts on its `Save`. A
-tag's text changing under it (a 9P write, a shorter tag) pulls the cursor
-back inside it.
+`Repl python` in a terminal's tag binds it as that language's REPL
+([fs.md](fs.md#repls)). `Repl` takes the languages a code fence names: ada,
+bash, c, c_sharp, clojure, cpp, css, elixir, erlang, fortran, go, haskell,
+html, java, javascript, json, kotlin, ocaml, markdown, pascal, php,
+powershell, python, ruby, rust, scala, typst, zig, and aliases such as `py`
+and `sh`.
-Moving between tags is the window keys' job, as it is between bodies
-(`Ctrl-w` or `SPC w` with `h/j/k/l`): they move to the neighbouring pane's
-body. Up from a pane with nothing above it reaches its column's tag, then
-the workspace's; Down comes back the same way, and Left and Right walk the
-column tags.
+## Pane builtins
-Search (`/`), `s`/`S`, pipe (`|`) and Save's path prompt are not typed into
-the tag: each gets a line of its own on the pane's notice band, with the
-body's insert-mode keys. Pressed in a tag, `s`, `S` and `|` answer for the
-tag's own text, as they do for a body, and a header's go on the active pane's
-band; `/` searches the body wherever it is pressed, as acme's Look from a tag
-does.
+`Collapse` folds a pane to its tagline, giving its rows to the nearest
+expanded pane above (else below); again, it takes them back. Its text and
+process are kept.
-Pane filenames and commands now have a single separator space rather than
-generated right-alignment padding. Intentionally customized spacing is kept.
+`Del` closes a pane, giving its rows to one neighbour. `Del k` (`DelAbove`)
+gives them to the nearest expanded pane above, `Del j` (`DelBelow`) below,
+each falling back to the other side. Bare `Del` from the keyboard (`SPC d`),
+with expanded panes above and below, asks on the notice band (`k`/Up above,
+`j`/Down below, any other key keeps the pane); a click, a 9P write or an
+`init` line never asks and gives the rows above.
-## File names
+A pane with unsaved text is refused once: a notice `1 unsaved pane — Del
+again to discard`, the pane listed in `+Unsaved`, and over 9P the write
+fails (EIO). The same `Del` again, nothing edited since, discards. `Delcol`
+refuses a column holding such a pane the same way, without opening
+`+Unsaved` or moving focus. `Exit` and `Restore` do the same over the whole
+session. A `+New` scratch under 100 bytes is never asked about.
-Clicking a file pane's name, or typing into it from the tag, starts a draft
-of it, typed into where it was clicked or typed.
-Enter or Tab confirms the new buffer name; Escape or leaving the pane
-cancels the draft. Confirmation changes the
-buffer's save target and marks it unsaved. It does **not** rename, create or
-overwrite a disk file. A subsequent explicit `Save` writes the buffer to its
-committed name. Executing a pane-tag command confirms a valid name draft
-first, so a visible draft cannot silently save to the previous name.
+`Edit` runs sam's command language on the body ([fs.md](fs.md#edit)).
+`Undo` and `Redo` step the body through its last 256 edits, as `u` and `U`.
-Terminal working directories and generated image/PDF status remain managed
-by their corresponding commands. Their command tails are editable just like
-file command tails.
+Unsaved text shows on the pane's grip, as acme's modbutton, not in the tag.
+In a terminal the grip is two cells: the pane's mode (blank normal, `^`
+insert, `$` tty mode), then `*` while unsaved.
-PDF tags follow the same filename-first layout: `manual.pdf [1/12] Tty Del
-PdfSections PdfTint Collapse`. Sections and tint commands remain in the editable
-tail, without displaying the current tint state. `PdfFit` (`SPC t z`) remains
-available, as do the shortcuts for `PdfTint` (`SPC t i`) and `PdfSections`
-(`SPC t s`, or `f` on a PDF).
+## Editing tags
-Terminal tags include `Mode`, which cycles through raw terminal input, normal
-editor mode, insert mode, and back to terminal input. On files and text output
-panes, `Mode` cycles between normal and insert mode; it is available as a command
-but does not appear in their default tags. Images and PDFs keep their normal mode.
-Executing `Mode` from a tag leaves the tag and advances the parked body mode.
+A tag is text like a body, with the body's normal and insert modes and undo.
+A pane tag may hold several lines and wraps, taking a row per shown line up
+to eight and leaving its body at least one; a collapsed pane shows the first.
+Up on its first row in insert mode (or Alt-Up in either mode) folds a tag to
+one row, Down on its last (Alt-Down) unfolds it. Column and workspace tags are
+one line: a newline typed, pasted or written into one becomes a space.
-Ctrl-B keeps its terminal/editor toggle. Existing custom tags can still use
-`Togglettymode` for that two-way terminal toggle. Old default terminal tags
-upgrade to `Mode`; customized command text is preserved.
+The path (and a PDF's page) at the start of a pane tag is computed and
+read-only; `0` goes to its start, and motions select and yank across it.
+Typing into a file's path, or clicking it, starts a draft of a new name:
+Enter or Tab confirms it, Escape or leaving the pane cancels. Confirming
+changes the buffer's save target and marks it unsaved; nothing on disk is
+renamed until `Save`. A pane-tag command confirms a valid draft first.
-## Saved workspaces
+Left-click a tag to type at the click, in insert mode. Esc is normal mode:
+there Tab executes the word under the cursor (or the selection); Enter looks
+it up in a pane's tag and runs it in a column or workspace tag. Executing
+gives the keyboard back to the body first. Paste goes to the focused tag.
-`Dump` and `Restore` preserve customized workspace and column tags, including
-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
-tag.
+`:` in a body's normal mode focuses its tag (the first time, on `Save`); `:`
+in a tag goes back. The window keys (`Ctrl-w`, `SPC w` with `h/j/k/l`) move
+between panes; up from the top pane reaches its column's tag, then the
+workspace's, and Left/Right walk the column tags. Search, `s`/`S`, pipe and
+Save's path prompt get a line on the pane's notice band; in a tag `s`, `S`
+and `|` act on the tag's own text, while `/` searches the body.
-The column row stays above panes with `TagBottom` enabled. On screens shorter
-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.
+## Moving and resizing
-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.
+Drag a pane's grip up or down its column and its top follows, the pane
+above giving or taking rows, down to its tag alone; drop it in another
+column and it moves there. In the GUI the 2 px rule between panes drags too.
+A column's right edge drags its width. Drag a column's grip past a
+neighbour's middle to move the whole column there; short of that it moves
+the column's left edge.
-Unsaved text shows on the pane's grip button, as acme's modbutton does,
-not in the tag: the tag names the file and nothing more. In a terminal the
-grip is two cells: the second is `*` while the pane holds unsaved text, and
-the first is the pane's mode: blank in normal mode, `^` in insert, `$` when
-the keyboard goes to a terminal's program (tty mode). The `dirty` file
-and `index`'s flag say the same to a script.
+A terminal keeps its tag and 2 body rows: no drag, squeeze or smaller
+window takes it below that, and `pty/ctl`'s `winsize` gives a pty 2 rows at
+least. A text pane can be dragged down to its tag.
## Empty columns
A column can hold no pane, as acme's can: its tag stands over blank space in
-the theme's `empty_col` colour, white in the acme theme as acme paints it
-(cols.c:186-188), and the frame's border fill in themes that do not set it. `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.
+the theme's `empty_col` colour. `Newcol` makes one right of the keyboard's
+and gives its tag the keyboard. Closing a column's last pane leaves the
+column empty, the keyboard on its tag. Only `Delcol` and `Joincol` take a
+column away; `Joincol` keeps the right column's tag, its panes below. A pane
+dragged onto an empty column fills it. `Delcol` of the last column leaves
+the workspace tag alone; `Newcol` or `New` starts again. Unlike acme, pardes
+quits when the session's last pane closes.
## 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.
+Every new pane goes through one placement, chosen by `Placement acme` (the
+default) or `Placement pardes` (bare flips it; `SPC c p`).
-Under either, no placement leaves a pane, new or split, shorter than its tag
-and two body rows (acme's minht keeps one). Where the place chosen has not
-that room, the column's tallest pane is halved instead; where no one pane
-can give it but the column's rows hold every pane's tag and two rows with
-the new one's, the rows are shared out again, each at least its minimum;
-only when they do not -- the arithmetic, not the halving, decides -- is
-the new pane refused and closed, with `no space for a
-pane in that column: each keeps its tag and 2 rows` (a 9P write or open of
-`pane/new` fails with it, ENOSPC). A pane alone in its column always fits.
+No placement leaves a pane shorter than its tag and 2 body rows. Where the
+chosen place has not that room, the column's tallest pane is halved; where
+no one pane can give it but the column holds every pane's minimum with the
+new one's, the rows are shared out again; otherwise the pane is refused,
+`no space for a pane in that column: each keeps its tag and 2 rows` (ENOSPC
+over 9P). A pane alone in its column always fits.
-After placement a terminal keeps that floor too: no drag of a grip, no
-squeeze of its column by the others' weights and no smaller window takes
-it below its tag and two body rows (a one-row terminal loses its prompt's
-mark and would read busy for ever); its neighbours give the rows, and only
-a window too short for every floor leaves it less. A text pane keeps
-acme's way and can be dragged down to its tag alone; acme has no
-terminals to follow here. A folded terminal (Collapse) is a tag by choice.
-`pty/ctl`'s `winsize` likewise gives a pty two rows at least.
-A `+Errors` pane goes to any column with room, the last first; with none,
-what it would have shown (an Edit's `p` or `=`, a write to `errors`) is
-logged as `msg` records, a line each, and the Edit still succeeds.
+`Placement acme` is acme's makenewwindow. The active column is the one last
+typed or clicked in, dropped into, whose tag was given the keyboard, or that
+was given the last new pane; a Look moves the keyboard, not the active
+column. A new pane goes into the column whose tag the command came from,
+else the active column, never into a new column:
-`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;
+- from a tag, or 9P's `pane/new`, it takes the bottom half of the column's
+ last pane;
+- from a pane's text (a Look, `Tty`, `Alt-n`, a Grep or Find listing), it
+ goes 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;
+ otherwise it halves the biggest pane, or the asking pane when that is in
+ the column and not much smaller;
+- `New` goes to the bottom half of its own column's last pane;
+- a command pane or `+Errors` pane goes to the last column's last pane (a
+ command from a column's tag to that column, reusing a finished command
+ pane only there). With no room anywhere, `+Errors` text is logged as `msg`
+ records.
-- 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); a command run from a column's tag
- goes to that column instead, and reuses a finished command pane only in
- that column.
+`Placement pardes`: an empty column whose tag asked, or has the keyboard, is
+filled; a scratch goes 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.
-`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.
-
-`BootShell replace` brings back one more placeholder: a document dragged into
-the left column closes the column's lone shell if nobody has typed into it.
-`BootShell keep`, the default, never closes a pane for another one.
-
-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.
+## Saved workspaces
-`Delcol` of the last column does as acme's does: the column goes and the
-window stays, empty but for the workspace tag, where `Newcol` (or `New`,
-which makes the column it goes in) starts it again. One divergence from
-acme remains: acme keeps running when its last window closes; pardes quits
-when the session's last pane closes by `Del`, a shell exiting or the like.
+`Dump` and `Restore` keep workspace and column tags (empty ones too) and
+empty columns ([config.md](config.md#dumps)). The column row stays above
+the panes with `Tagbottom` on; on screens under three rows it is left out.
diff --git a/docs/v9fs.md b/docs/v9fs.md
index a105fe06..26460658 100644
--- a/docs/v9fs.md
+++ b/docs/v9fs.md
@@ -1,114 +1,56 @@
-# Linux terminals with a kernel 9P mount
+# Tty9p: a terminal with a kernel 9P mount
-Execute `Tty9p`, or press `SPC n 9` in editor mode, to open a terminal below
-this pane with the current Pardes session mounted through Linux v9fs.
-
-The new pane asks for your sudo password when needed. After mounting, it starts
-your configured shell as your normal user, with your account's supplementary
-groups. The shell receives `PARDES_MOUNT`, the absolute mountpoint:
+On Linux, `Tty9p` (`SPC n 9`) opens a terminal below this pane with the
+session mounted through the kernel's v9fs. The pane asks for your sudo
+password, mounts, and starts your shell as your normal user (with your
+supplementary groups). The shell gets `PARDES_MOUNT`, the mountpoint:
```sh
-ls "$PARDES_MOUNT/pane"
cat "$PARDES_MOUNT/index"
-cat "$PARDES_MOUNT/README"
cat "$PARDES_MOUNT/pane/$PARDES_PANE/body"
echo 'Msg hello' > "$PARDES_MOUNT/exec"
n=$(cat "$PARDES_MOUNT/pane/new")
```
-**Opening** `pane/new` makes a pane, and reading that open file answers its
-serial; address the pane as `pane/<serial>` from then on. Each open makes
-another one, so read it once and keep the number. A *stat* makes nothing,
-which is why `new` can be listed at all: `ls`, `ls -l` and `find` over the
-whole mount create nothing, because none of them open it.
-
-The mount belongs to that pane's subprocess tree. Other panes and the editor
-core keep their original mount namespace. It works in native Linux TTY and SDL
-sessions, including detached sessions. A frontend attaching from elsewhere does
-not perform the mount; the session host starts the new terminal.
-
-## Build and setup
-
-The normal Linux build installs the ordinary `pardes-v9fs` executable beside
-`pardes` and `pardes-gui`:
-
-```sh
-zig build
-```
+The files are those of [fs.md](fs.md). The mount lives in that pane's
+private mount namespace: other panes and the editor do not see it, so a
+Look at a path under `$PARDES_MOUNT` opens nothing. Each `Tty9p` makes its
+own mount and takes one of the session's 16 Unix/TCP connection slots. It
+works in TTY, SDL and detached sessions (the session host starts the shell,
+not an attached frontend). Dump/Restore does not remake the mount.
-Start an updated editor to get the builtin. An already running core retains
-its old code. The host resolves the helper beside its own executable;
-`PARDES_V9FS_HELPER=/absolute/path/to/pardes-v9fs` overrides this for development
-builds whose editor and helper live in different build-cache directories.
+## Setup
-Linux must support `9p` and its Unix transport (`9pnet_fd`). v9fs mounting needs
-`CAP_SYS_ADMIN` in the initial user namespace, which sudo supplies. The launcher
-uses `sudo -E` to retain the shell environment; local sudo policy must allow
-that. It restores the caller's PATH after dropping privileges because sudo's
-`secure_path` can replace it even with `-E`.
+The build installs `pardes-v9fs` beside `pardes`; the host looks for it
+beside its own executable, or at `PARDES_V9FS_HELPER` (an absolute path).
+A running editor keeps the code it started with.
-No setuid installation, passwordless sudo policy, system group, or FUSE is
-installed. The helper currently accepts explicit mount paths and a command;
-it is not a restricted privilege broker. Do not grant it blanket passwordless
-access. A group-authorized helper would require a separate restricted design.
+Linux needs `9p` and its Unix transport (`9pnet_fd`); mounting needs
+`CAP_SYS_ADMIN`, which sudo supplies. The launcher runs `sudo -E` (local
+policy must allow it) and restores the caller's `PATH` after dropping
+privileges. Nothing setuid, no passwordless sudo rule and no FUSE is
+installed. The helper takes explicit mount paths and a command; it is not a
+restricted privilege broker, so do not grant it passwordless sudo.
-## Runtime organization
+## How it works
-`src/linux/v9fs.zig` builds `pardes-v9fs` and provides helper discovery to the
-native host. `Tty9p` marks the new pane for a mounted shell; `host_io.forkShell`
-starts the normal interactive shell and queues a quoted helper command. For
-bash and fish, the command waits for the shell's prompt-ready mark. The helper
-runs as a foreground shell job, so sudo uses the terminal like a manually run
-command. Authentication failure or cancellation returns to the original shell;
-exiting the mounted shell also returns there.
+`Tty9p` starts the normal shell and queues a quoted helper command, which
+bash and fish run once their prompt is ready, as a foreground job, so sudo
+uses the terminal. A failed or cancelled authentication returns to that
+shell, as does exiting the mounted one. The unprivileged launcher makes a
+private temporary mountpoint and runs sudo; the elevated helper makes a
+private mount namespace, mounts the session's socket with
+`trans=unix,version=9p2000,cache=none,access=any,nosuid,nodev,noexec`, drops
+every root id and capability, and executes the shell. The namespace, and
+the mount, go when its last process exits. Code: `src/linux/v9fs.zig`.
-The unprivileged launcher creates a private temporary mountpoint and invokes
-sudo inside the new PTY. The elevated helper creates a private mount namespace,
-makes propagation recursively private, and mounts the session's Unix socket
-with `version=9p2000,cache=none,access=any,nosuid,nodev,noexec`. It restores the
-calling user's account groups, drops all real/effective/saved root IDs and
-mount capabilities, and executes the shell. Ordinary commands such as sudo
-remain available for subsequent explicit authentication. The launcher waits for sudo,
-forwards termination signals, and removes its empty temporary directory on exit.
-Namespace destruction releases the mount when its last process exits.
-
-The core stays outside the mount namespace, which is the helper's private
-one: a path under `$PARDES_MOUNT` names nothing in the editor's own namespace,
-so a Look on one from that shell opens nothing.
-
-Each `Tty9p` currently creates its own mount and consumes a server connection;
-the session has four application connection slots across all transports. This
-is the explicit per-pane version. A shared launcher for all pane shells remains
-future work. Detached sessions retain the running mounted pane when frontends
-leave; Dump/Restore does not reconstruct mounted-shell namespaces. Descendants
-that deliberately outlive the terminal may retain their namespace until exit.
+Linux follows `O_TRUNC` with a `Twstat` of zero length and an `mtime` hint;
+pardes takes the truncation and drops the hint.
## Tests
```sh
-zig build v9fs-terminal-test -Dplatform=tty
-zig build v9fs-driver-test -Dplatform=tty
-zig build 9p-test -Dplatform=tty
-sudo -v
-zig build v9fs-test -Dplatform=tty
+zig build v9fs-terminal-test -Dplatform=tty # builtin, launcher, cleanup; a sudo stand-in, no mount
+zig build v9fs-driver-test -Dplatform=tty # the probe launcher, no privileges
+sudo -v; zig build v9fs-test -Dplatform=tty # a real kernel mount (sudo -n); fails, not skips, without it
```
-
-`v9fs-terminal-test` exercises the builtin, real launcher, PTY input, session
-environment, quoted helper paths, hidden password input, and core responsiveness
-using an unprivileged sudo stand-in. It checks that authentication failure and
-interruption clean up the temporary mountpoint and leave the original shell usable. It does not claim kernel-mount coverage.
-
-`v9fs-test` mounts through the same runtime helper. Its driver keeps the editor
-unprivileged and uses `sudo -n`, retaining the calling terminal's authorization.
-Missing authorization or kernel support fails the test instead of skipping it.
-It checks mount isolation, privilege dropping, inherited access, directory
-refresh, body reads and truncation, independent wire updates, addressed edits,
-shell redirection to name, Exec dispatch, rendered screen JSON, and OS-file reads.
-
-Linux follows `O_TRUNC` with a `Twstat` carrying zero length and an `mtime` hint.
-Pardes accepts this truncation without storing caller-selected timestamps;
-standalone timestamp, permission, and ownership changes remain unsupported.
-
-The initial kernel probe passed on this host on 2026-09-14. The broader
-`fs-test` has an existing syntax-bold assertion failure (now `test/fs.py:615`),
-also reproduced on the cached editor binary preceding the truncation fix.
diff --git a/src/9p.zig b/src/9p.zig
index 5ffe9058..837525cb 100644
--- a/src/9p.zig
+++ b/src/9p.zig
@@ -53,7 +53,7 @@ pub const msize_min = cloud9.fs.msize_min;
/// The smallest msize a native listener offers (src/9p_io.zig).
pub const min_msize: u32 = 4096;
/// What a native listener negotiates.
-pub const msize: u32 = 8192;
+pub const msize: u32 = 65536;
/// The editor's engine: native fid count, native file names, and room to
/// park a write of any size a frame can carry -- a request that would change
diff --git a/src/File.zig b/src/File.zig
index 9f0f329c..8babb27d 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -16,8 +16,8 @@ const limits = @import("memory.zig").limits;
const locations = @import("locations.zig");
const Pane = panes.Pane;
const Output = panes.Output;
-const Mini = panes.Mini;
-const Terminal = panes.Terminal;
+const Mini = panes.mini;
+const terminal = panes.terminal;
const Text = panes.Text;
const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
@@ -782,7 +782,7 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
std.debug.assert(p.panes[id] == null and !p.reserved_slots[id]);
const path_copy = try p.gpa.dupe(u8, path);
errdefer p.gpa.free(path_copy);
- const pane = try Terminal.createDoc(p.gpa, p.screen_w, p.screen_h);
+ const pane = try terminal.createDoc(p.gpa, p.screen_w, p.screen_h);
errdefer p.gpa.destroy(pane);
const history = try History.create(p.gpa);
errdefer p.gpa.destroy(history);
@@ -1239,7 +1239,7 @@ fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool)
pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void {
const prefix_width = gutterWidth(pane);
const goff = pane.scroll();
- const gcur = Terminal.gridCursor(pane);
+ const gcur = terminal.gridCursor(pane);
const gcrow = if (pane.body.cur_pinned) pane.body.cur_row else @as(i32, gcur.y) + goff;
const typing_body = pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice);
const cur_line: i32 = if (active and typing_body) gcrow else std.math.minInt(i32);
diff --git a/src/Layer.zig b/src/Layer.zig
index a9827304..7a5fcf7e 100644
--- a/src/Layer.zig
+++ b/src/Layer.zig
@@ -199,3 +199,31 @@ test "a taller tag's rows are grid rows, each hit on its own line" {
try std.testing.expect(layer.tagHit(120, 140, 10, 20, 6, 12) == null);
try std.testing.expect(layer.bodyHit(120, 100, 10, 20, 6, 12) == null);
}
+
+// Pointer hits on tag bands: comparing two, and bringing one that left
+// its band back onto it.
+
+/// Whether two pointer hits name the same cell of the same tag line.
+pub fn sameCell(a: ?TagHit, b: ?TagHit) bool {
+ const first = a orelse return b == null;
+ const second = b orelse return false;
+ return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.line == second.line and first.col == second.col;
+}
+
+/// The tag column a host's pointer is at. Clamped, a point off the tag is
+/// brought back onto line `line` of it, for a drag that leaves it.
+pub fn columnAt(p: *const pardes.Pardes, kind: Kind, id: usize, supplied: ?TagHit, clamp: bool, line: u16) ?u16 {
+ var point = supplied orelse return null;
+ if (point.kind != kind or point.id != id) return null;
+ if (clamp) for (p.surface.tagLayers()) |*layer| {
+ if (layer.rows == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or line >= layer.rows) continue;
+ const bw: f32 = @floatFromInt(point.metrics.body_w);
+ const bh: f32 = @floatFromInt(point.metrics.body_h);
+ const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
+ const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw;
+ point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001));
+ point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y + line)) + 0.5) * bh;
+ break;
+ };
+ return if (p.reprojectTagHit(point)) |mapped| mapped.col else null;
+}
diff --git a/src/Messages.zig b/src/Messages.zig
index 7342f726..7dcb22ed 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -51,13 +51,13 @@ pub const LoggedMessage = struct {
fn leaderText(p: *const Pardes, buf: *[16]u8) []const u8 {
buf.* = @splat(' ');
@memcpy(buf[0..3], "SPC");
- var at: usize = 3;
+ var end: usize = 3;
for (p.leader_keys[0..p.leader_n]) |ch| {
- if (at + 2 > buf.len) break;
- buf[at + 1] = ch;
- at += 2;
+ if (end + 2 > buf.len) break;
+ buf[end + 1] = ch;
+ end += 2;
}
- return buf[0..at];
+ return buf[0..end];
}
/// How wide a notice chip is, in grid columns: its own text plus a blank
@@ -437,7 +437,7 @@ fn logMessage(p: *Pardes, id: usize, text: []const u8) void {
}
/// The log oldest-first, which is reading order.
-pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage {
+pub fn at(m: *const Messages, i: usize) ?*const LoggedMessage {
if (i >= m.len) return null;
const first = (m.head + limits.message_log - m.len) % limits.message_log;
return &m.log[(first + i) % limits.message_log];
@@ -503,18 +503,14 @@ pub fn clip(text: []const u8, max: usize) []const u8 {
/// reportError with the words already chosen.
pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
p.fs.failures +%= 1;
- // A builtin a ctl write runs: its first error is also the write's, cut
- // between words.
+ // A builtin a ctl write runs: its first error is also the write's, its
+ // path shortened in the middle if it must be, never its reason.
const failing_write = p.fs.no_prompt or p.fs.capturing or p.fs.write_waits;
if (failing_write and p.fs.failure_len == 0) {
- const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text;
+ var room: @TypeOf(p.fs.failure) = undefined;
+ const kept = pardes.ctlfs.fitErr(text, &room);
@memcpy(p.fs.failure[0..kept.len], kept);
- var n = kept.len;
- if (kept.len < text.len) {
- @memcpy(p.fs.failure[n..][0..3], "...");
- n += 3;
- }
- p.fs.failure_len = @intCast(n);
+ p.fs.failure_len = @intCast(kept.len);
}
// The write fails with it, and its err record says it: no msg for it,
// so the same failure again is the same record again, counted
@@ -584,13 +580,13 @@ test "the message log keeps what the row forgets, and collapses repeats" {
setMessage(p, 0, "save: AccessDenied");
try std.testing.expectEqual(@as(usize, 2), p.messages.len);
- const first = p.messages.messageLog(0).?;
+ const first = p.messages.at(0).?;
try std.testing.expectEqual(@as(u16, 2), first.repeats);
// ...and the NEWEST wording is what survives, so the row carries the last
// time it happened rather than the first.
try std.testing.expectEqualStrings("14:32:09 saved /x.zig", first.slice());
- try std.testing.expectEqualStrings("save: AccessDenied", p.messages.messageLog(1).?.slice());
- try std.testing.expect(p.messages.messageLog(2) == null);
+ try std.testing.expectEqualStrings("save: AccessDenied", p.messages.at(1).?.slice());
+ try std.testing.expect(p.messages.at(2) == null);
// The same text from a DIFFERENT pane is a different event: one pane's
// failure must not be recorded as another's.
@@ -598,7 +594,7 @@ test "the message log keeps what the row forgets, and collapses repeats" {
const other = p.active;
setMessage(p, other, "save: AccessDenied");
try std.testing.expectEqual(@as(usize, 3), p.messages.len);
- try std.testing.expectEqual(p.panes[other].?.serial, p.messages.messageLog(2).?.serial);
+ try std.testing.expectEqual(p.panes[other].?.serial, p.messages.at(2).?.serial);
// Progress is NOT logged: it arrives several times a second for a whole
// index and would push everything else out (`setStatus`).
@@ -611,10 +607,10 @@ test "the message log keeps what the row forgets, and collapses repeats" {
setMessage(p, 0, std.fmt.bufPrint(&buf, "line {d}", .{i}) catch unreachable);
}
try std.testing.expectEqual(@as(usize, limits.message_log), p.messages.len);
- try std.testing.expectEqualStrings("line 5", p.messages.messageLog(0).?.slice());
+ try std.testing.expectEqualStrings("line 5", p.messages.at(0).?.slice());
var last_buf: [32]u8 = undefined;
const want_last = std.fmt.bufPrint(&last_buf, "line {d}", .{limits.message_log + 4}) catch unreachable;
- try std.testing.expectEqualStrings(want_last, p.messages.messageLog(limits.message_log - 1).?.slice());
+ try std.testing.expectEqualStrings(want_last, p.messages.at(limits.message_log - 1).?.slice());
}
test "Msg writes the transient row by hand, bare or with text, and input ends it" {
diff --git a/src/Output.zig b/src/Output.zig
index 6ea81924..fb8fe7a3 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -844,7 +844,7 @@ pub fn openMessages(p: *Pardes, id: usize) !void {
var out: std.Io.Writer.Allocating = .init(p.gpa);
errdefer out.deinit();
var i: usize = 0;
- while (p.messages.messageLog(i)) |m| : (i += 1) {
+ while (p.messages.at(i)) |m| : (i += 1) {
if (m.serial != 0) try out.writer.print("{d}: ", .{m.serial});
try out.writer.writeAll(m.slice());
if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats});
diff --git a/src/shader_build.zig b/src/ShaderBuild.zig
index c2610fd7..c2610fd7 100644
--- a/src/shader_build.zig
+++ b/src/ShaderBuild.zig
diff --git a/src/Text.zig b/src/Text.zig
index ab23bf8e..cef76b3b 100644
--- a/src/Text.zig
+++ b/src/Text.zig
@@ -14,7 +14,7 @@ const config = @import("config.zig");
const memory = @import("memory.zig");
const Pane = panes.Pane;
const File = panes.File;
-const Terminal = panes.Terminal;
+const terminal = panes.terminal;
const Text = @This();
pub const Mode = enum { normal, insert, tty };
@@ -84,9 +84,9 @@ normal: modal.Normal.State = .{},
/// last f/F/t/T motion, for Alt-. repeat
find_op: u8 = 0,
find_ch: u21 = 0,
-ed_undo: [Terminal.history_max]Terminal.Snapshot = undefined,
+ed_undo: [terminal.history_max]terminal.Snapshot = undefined,
ed_undo_len: usize = 0,
-ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined,
+ed_redo: [terminal.history_max]terminal.Snapshot = undefined,
ed_redo_len: usize = 0,
/// Set when an edit would have changed characters this text does not own
/// (a tag's computed prefix) and was refused; the key that tried it puts
@@ -706,10 +706,10 @@ pub fn clampCursor(t: *Text, text: []const u8, row0: i32) void {
t.show();
}
-fn pushHistory(gpa: std.mem.Allocator, slots: []Terminal.Snapshot, len: *usize, value: Terminal.Snapshot) void {
+fn pushHistory(gpa: std.mem.Allocator, slots: []terminal.Snapshot, len: *usize, value: terminal.Snapshot) void {
if (len.* == slots.len) {
if (slots[0].ovl) |overlay| gpa.free(overlay.text);
- std.mem.copyForwards(Terminal.Snapshot, slots[0 .. slots.len - 1], slots[1..]);
+ std.mem.copyForwards(terminal.Snapshot, slots[0 .. slots.len - 1], slots[1..]);
len.* -= 1;
}
slots[len.*] = value;
@@ -719,14 +719,14 @@ fn pushHistory(gpa: std.mem.Allocator, slots: []Terminal.Snapshot, len: *usize,
/// Record an edit buffer (a terminal's overlay, a tag's own text) as it
/// stands before an edit, once per change of it, and forget what could have
/// been redone. Null is a buffer not made yet.
-pub fn remember(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer) void {
+pub fn remember(t: *Text, gpa: std.mem.Allocator, current: ?terminal.EditBuffer) void {
if (t.ed_undo_len > 0) {
const top = t.ed_undo[t.ed_undo_len - 1];
const same = if (top.ovl) |overlay| if (current) |now| overlay.row == now.row and
overlay.rows == now.rows and std.mem.eql(u8, overlay.text, now.text) else false else current == null;
if (same) return;
}
- const copy: ?Terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return } else null;
+ const copy: ?terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return } else null;
pushHistory(gpa, &t.ed_undo, &t.ed_undo_len, .{ .ovl = copy, .cur_row = t.cur_row, .cur_col = t.cur_col, .vsel = t.vsel });
for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text);
t.ed_redo_len = 0;
@@ -736,13 +736,13 @@ pub fn remember(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer)
/// other history and the state to return to comes back, its cursor and
/// selection already restored here. The caller installs its buffer, which
/// it then owns. Null when there is nothing to step to.
-pub fn step(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer, back: bool) ?Terminal.Snapshot {
+pub fn step(t: *Text, gpa: std.mem.Allocator, current: ?terminal.EditBuffer, back: bool) ?terminal.Snapshot {
const from, const from_len, const to, const to_len = if (back)
.{ &t.ed_undo, &t.ed_undo_len, &t.ed_redo, &t.ed_redo_len }
else
.{ &t.ed_redo, &t.ed_redo_len, &t.ed_undo, &t.ed_undo_len };
if (from_len.* == 0) return null;
- const copy: ?Terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return null } else null;
+ const copy: ?terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return null } else null;
pushHistory(gpa, to, to_len, .{ .ovl = copy, .cur_row = t.cur_row, .cur_col = t.cur_col, .vsel = t.vsel });
from_len.* -= 1;
const back_to = from[from_len.*];
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 6e1fe8f1..cb26c1f2 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -51,7 +51,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [
var visible: i32 = 0;
while (lines.next()) |line| : (visible += 1) {
if (visible >= r.h -| pane.tag_rows) break;
- const source_row = if (raw) panes.Terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line;
+ const source_row = if (raw) panes.terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line;
for (rows) |row| {
const target = if (row.raw_terminal)
(if (raw) row.row else pane.surfRow(row.row))
@@ -64,7 +64,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [
lo -|= row.prompt_bytes;
hi -|= row.prompt_bytes;
} else if (!row.raw_terminal and raw) {
- const prefix = panes.Terminal.promptPrefixBytes(pane, source_row, line);
+ const prefix = panes.terminal.promptPrefixBytes(pane, source_row, line);
lo += prefix;
hi += prefix;
}
@@ -85,7 +85,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [
fn paintSourceSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void {
if (pane.isTerminal()) return paintTerminalSelection(p, s, pane, r, rows, bg, fg);
- const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{};
+ const terminal_lines = if (pane.file == null) panes.terminal.cursorLines(p, pane) catch return else &.{};
const tx = r.x + config.GUTTER;
const width = r.w -| config.GUTTER;
const body_y = p.bodyTop(pane, r);
@@ -291,7 +291,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
panes.File.recolorSyntax(p, s, pane, f, r, tx, tw, body_h);
panes.File.drawWrapMarkers(p, s, pane, r, tx, tw, body_h, pane_bg);
} else if (pane.isTerminal() and p.settings.colors) {
- panes.Terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body);
+ panes.terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body);
}
tz_color.end();
@@ -440,8 +440,8 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
// (the tag's or a prompt's cursor wins while that is being typed into)
if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice)) {
if (pane.body.mode != .tty) {
- const cur = panes.Terminal.gridCursor(pane);
- const goff = panes.Terminal.gridOffset(pane);
+ const cur = panes.terminal.gridCursor(pane);
+ const goff = panes.terminal.gridOffset(pane);
const crow = if (pane.body.cur_pinned) pane.body.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff);
const ccol = if (pane.body.cur_pinned) pane.body.cur_col else @as(i32, @intCast(cur.x));
const cwp = pane.wrapRow(crow, ccol);
@@ -460,7 +460,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
ccol;
if (prow >= pane.tag_rows and cx >= 0 and prow < body_bottom and cx < tw)
s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - pane.tag_rows)), .bar = pane.body.mode == .insert };
- } else if (panes.Terminal.visibleCursor(pane)) |cur| {
+ } else if (panes.terminal.visibleCursor(pane)) |cur| {
if (cur.y + pane.tag_rows < body_bottom and cur.x < tw)
s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y };
}
@@ -492,5 +492,5 @@ pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
unreachable;
}
if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap);
- return panes.Terminal.bodyText(arena, pane);
+ return panes.terminal.bodyText(arena, pane);
}
diff --git a/src/builtins.zig b/src/builtins.zig
index e47afeb4..c549b17f 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -574,7 +574,8 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
std.fmt.bufPrint(&said_buf, "{s}: Modified ({s} again to discard)", .{ one, @tagName(asking) })
else
std.fmt.bufPrint(&said_buf, "{d} unsaved panes: Modified ({s} again to discard)", .{ count, @tagName(asking) })) catch "unsaved panes: Modified";
- const kept = @import("Messages.zig").clip(said, c.p.fs.failure.len);
+ var room: @TypeOf(c.p.fs.failure) = undefined;
+ const kept = pardes.ctlfs.fitErr(said, &room);
@memcpy(c.p.fs.failure[0..kept.len], kept);
c.p.fs.failure_len = @intCast(kept.len);
}
@@ -624,7 +625,7 @@ pub const Kill = struct {
stopped = true;
continue;
};
- if (!panes.Terminal.commandRunning(pane)) continue;
+ if (!panes.terminal.commandRunning(pane)) continue;
const sent = pane.sent_command.?;
if (names.len > 0) {
var words = std.mem.tokenizeAny(u8, names, " \t");
@@ -1378,7 +1379,7 @@ pub const Mini = struct {
pub const output: OutputTraits = .{ .name = "Mini", .doc = true };
pub fn run(c: Ctx) void {
- panes.Mini.open(c.p, c.id, c.arg orelse "") catch |err|
+ panes.mini.open(c.p, c.id, c.arg orelse "") catch |err|
c.p.reportError(c.id, "Mini", err);
}
};
diff --git a/src/colors.zig b/src/colors.zig
index 72e25aed..b6da0d2c 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -153,18 +153,22 @@ fn toOklab(rgb: [3]u8) [3]f32 {
};
}
-fn fromOklab(lab: [3]f32) [3]u8 {
+fn oklabLinear(lab: [3]f32) [3]f32 {
const lm = lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2];
const mm = lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2];
const sm = lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2];
const l = lm * lm * lm;
const m = mm * mm * mm;
const s = sm * sm * sm;
- const linear: [3]f32 = .{
+ return .{
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
};
+}
+
+fn fromOklab(lab: [3]f32) [3]u8 {
+ const linear = oklabLinear(lab);
var out: [3]u8 = undefined;
for (&out, linear) |*o, v| {
const x = std.math.clamp(v, 0, 1);
@@ -181,6 +185,72 @@ pub fn mixOklab(a: [3]u8, b: [3]u8, t: f32) [3]u8 {
return fromOklab(.{ x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t });
}
+fn oklabDistance(a: [3]f32, b: [3]f32) f32 {
+ return @sqrt((a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]) + (a[2] - b[2]) * (a[2] - b[2]));
+}
+
+/// How far a name's tint stands off its tag's ink in OKLab: a hue you see
+/// at a glance, not a shade you look for.
+pub const name_tint_distance: f32 = 0.07;
+/// How much hue a colour needs to lend a name its tint.
+const name_tint_chroma: f32 = 0.06;
+
+/// A name's tint for a theme that sets none (the faithful ports, the
+/// imports, a theme file): a hue of the theme's own -- its keyword colour,
+/// else the first of its ANSI accents, syntax, grip, selection or
+/// diagnostic colours that has one -- at the lightness nearest the tag's
+/// ink where that hue holds, the name reads on its ground (nameTintFloor)
+/// and it stands off the ink. Null when no colour of the theme will do.
+fn derivedNameTint(th: *const Theme, ink: [3]u8, ground: [3]u8) ?[3]u8 {
+ const pal = th.palette orelse [_][3]u8{th.kw} ** 16;
+ const sources = [_][3]u8{ th.kw, pal[4], pal[5], pal[6], pal[3], pal[2], pal[1], th.num, th.str, th.box } ++
+ [_][3]u8{ th.column_box orelse th.num, th.box_dirty orelse th.num, th.sel_bg, th.search_bg orelse th.num } ++
+ [_][3]u8{ th.diagnostic_info orelse th.num, th.diagnostic_hint orelse th.num, th.diagnostic_warning orelse th.num } ++
+ // A theme with no hue of its own at all (flatwhite): acme's DMedblue's.
+ [_][3]u8{.{ 0x00, 0x00, 0x99 }};
+ // Near the ink's lightness first, from any of the theme's hues; only
+ // then further off it.
+ for ([2]f32{ 0.15, 1 }) |reach| for (sources) |source| {
+ if (nameTintFrom(toOklab(source), ink, ground, reach)) |tint| return tint;
+ };
+ return null;
+}
+
+fn nameTintFrom(source: [3]f32, ink: [3]u8, ground: [3]u8, reach: f32) ?[3]u8 {
+ const chroma = @sqrt(source[1] * source[1] + source[2] * source[2]);
+ if (chroma < name_tint_chroma) return null;
+ const ink_lab = toOklab(ink);
+ const floor = nameTintFloor(ink, ground);
+ var step: f32 = 0;
+ while (step <= reach) : (step += 0.01) {
+ for ([2]f32{ 1, -1 }) |sign| {
+ const lightness = ink_lab[0] + sign * step;
+ if (lightness < 0 or lightness > 1) continue;
+ // As much of the hue as that lightness holds, up to a tint's.
+ var want = @min(chroma, 0.12);
+ while (want >= 0.04) : (want -= 0.01) {
+ const lab: [3]f32 = .{ lightness, source[1] / chroma * want, source[2] / chroma * want };
+ const in_gamut = for (oklabLinear(lab)) |v| {
+ if (v < -0.001 or v > 1.001) break false;
+ } else true;
+ if (!in_gamut) continue;
+ const tint = fromOklab(lab);
+ if (contrast(tint, ground) < floor) continue;
+ if (oklabDistance(toOklab(tint), ink_lab) < name_tint_distance) continue;
+ return tint;
+ }
+ }
+ }
+ return null;
+}
+
+/// What a name's tint must read at on its ground: 4.5:1, or within half a
+/// point of the ink's own where the ink has less to spare (seoulbones_dark's
+/// near-white on grey leaves no hue at the ink's 4.53:1).
+pub fn nameTintFloor(ink: [3]u8, ground: [3]u8) f32 {
+ return @min(4.5, contrast(ink, ground) - 0.5);
+}
+
test "an OKLab fade holds its ends exactly and keeps a mid-way bright" {
for ([_][3]u8{ .{ 0, 0, 0 }, .{ 255, 255, 255 }, .{ 0x0c, 0x0c, 0x0e }, .{ 0xe8, 0xc4, 0x6a }, .{ 0x1c, 0x51, 0x72 } }) |rgb| {
try std.testing.expectEqual(rgb, fromOklab(toOklab(rgb)));
@@ -548,13 +618,21 @@ pub const ChromeTheme = struct {
pub fn fromTheme(th: *const Theme) ChromeTheme {
// The initial chrome is resolved at compile time: contrast takes pow.
@setEvalBranchQuota(200_000);
+ const active_bg = focusTint(th);
+ const active_fg = th.tag_active_fg orelse th.tag_fg;
+ // A name is always tinted: a theme that names no tint gets one of
+ // its own hues, on each ground. One that names only the plain tint
+ // keeps it when focused too.
+ const name_fg = th.tag_name_fg orelse (derivedNameTint(th, th.tag_fg, th.tag_bg) orelse th.tag_fg);
+ const active_name_fg = th.tag_active_name_fg orelse
+ (th.tag_name_fg orelse (derivedNameTint(th, active_fg, active_bg) orelse active_fg));
return .{
.tag_bg = th.tag_bg,
.tag_fg = th.tag_fg,
- .tag_active_bg = focusTint(th),
- .tag_active_fg = th.tag_active_fg orelse th.tag_fg,
- .tag_name_fg = th.tag_name_fg orelse th.tag_fg,
- .tag_active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (th.tag_active_fg orelse th.tag_fg)),
+ .tag_active_bg = active_bg,
+ .tag_active_fg = active_fg,
+ .tag_name_fg = name_fg,
+ .tag_active_name_fg = active_name_fg,
.border = separatorOf(th),
.empty_col = th.empty_col orelse separatorOf(th),
.search_bg = th.search_bg orelse th.sel_bg,
@@ -1108,8 +1186,9 @@ test "legacy ThemeFile documents inherit Pardes UI roles without new fields" {
try std.testing.expectEqual(@as(?[3]u8, null), parsed.search_bg);
const chrome = ChromeTheme.fromTheme(&parsed);
try std.testing.expectEqual(parsed.tag_bg, chrome.tag_active_bg);
- try std.testing.expectEqual(parsed.tag_fg, chrome.tag_name_fg);
- try std.testing.expectEqual(chrome.tag_active_fg, chrome.tag_active_name_fg);
+ // Its names take a tint of its own hues, never the plain ink.
+ try std.testing.expect(!std.meta.eql(parsed.tag_fg, chrome.tag_name_fg));
+ try std.testing.expect(!std.meta.eql(chrome.tag_active_fg, chrome.tag_active_name_fg));
try std.testing.expectEqual(parsed.num, chrome.column_box);
try std.testing.expectEqual(mix(parsed.num, parsed.tag_bg), chrome.column_box_dim);
try std.testing.expectEqual(parsed.sel_bg, chrome.search_bg);
@@ -1148,8 +1227,9 @@ test "custom filename tints preserve optional active fallback precedence" {
theme.tag_name_fg = null;
theme.tag_active_name_fg = null;
var chrome = ChromeTheme.fromTheme(&theme);
- try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg);
- try std.testing.expectEqual(theme.tag_active_fg.?, chrome.tag_active_name_fg);
+ // Neither named: each derived, on its own ground.
+ try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_fg, theme.tag_bg).?, chrome.tag_name_fg);
+ try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_active_fg.?, chrome.tag_active_bg).?, chrome.tag_active_name_fg);
theme.tag_name_fg = .{ 120, 130, 140 };
chrome = ChromeTheme.fromTheme(&theme);
try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg);
@@ -1160,6 +1240,25 @@ test "custom filename tints preserve optional active fallback precedence" {
try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg);
theme.tag_name_fg = null;
chrome = ChromeTheme.fromTheme(&theme);
- try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg);
+ try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_fg, theme.tag_bg).?, chrome.tag_name_fg);
try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg);
}
+
+test "every theme tints its names off the tag's ink, and they read on both grounds" {
+ for (&themes) |*th| {
+ const chrome = ChromeTheme.fromTheme(th);
+ const pairs = [2][3][3]u8{
+ .{ chrome.tag_fg, chrome.tag_name_fg, chrome.tag_bg },
+ .{ chrome.tag_active_fg, chrome.tag_active_name_fg, chrome.tag_active_bg },
+ };
+ for (pairs) |pair| {
+ const ink, const name, const ground = pair;
+ const apart = oklabDistance(toOklab(ink), toOklab(name));
+ const floor = nameTintFloor(ink, ground);
+ if (apart < name_tint_distance or contrast(name, ground) < floor - 0.01)
+ std.debug.print("{s}: name {x} on {x} is {d:.3} off the ink {x}, {d:.2}:1\n", .{ th.name, name, ground, apart, ink, contrast(name, ground) });
+ try std.testing.expect(apart >= name_tint_distance);
+ try std.testing.expect(contrast(name, ground) >= floor - 0.01);
+ }
+ }
+}
diff --git a/src/detached/server.zig b/src/detached/server.zig
index b536e4a7..de26db13 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -11,7 +11,7 @@ const host_io = @import("../host_io.zig");
const selection_pipe = @import("../selection_pipe.zig");
const file_watch = @import("../file_watch.zig");
-const shader_build = @import("../shader_build.zig");
+const ShaderBuild = @import("../ShaderBuild.zig");
const ninep_io = @import("../9p_io.zig");
@@ -177,7 +177,7 @@ pub const Session = struct {
ninep_wake: std.atomic.Value(bool) = .init(false),
watches: file_watch.Table = @splat(null),
/// The post chain's files, compiled here for the GUIs attached.
- shaders: shader_build = .{},
+ shaders: ShaderBuild = .{},
check_files: bool = false,
in_loop: bool = false,
greet_deadline_ms: u32 = greet_deadline_default_ms,
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index 15d3641c..051e9317 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -3,7 +3,7 @@
const std = @import("std");
const pardes = @import("../pardes.zig");
const limits = @import("../memory.zig").limits;
-const shader_build = @import("../shader_build.zig");
+const ShaderBuild = @import("../ShaderBuild.zig");
const Scene = pardes.config.Runtime.Scene;
const ShaderAnimation = pardes.config.Runtime.ShaderAnimation;
@@ -364,15 +364,15 @@ pub const ServerMsg = union(enum) {
/// The session's post chain, as a GUI attached to it draws it: its passes in
/// order, each a bundled scene at its level or a Shadertoy file's SPIR-V
-/// (compiled by the session, shader_build.zig: a frontend reads no disk and
+/// (compiled by the session, ShaderBuild.zig: a frontend reads no disk and
/// runs no program), and when it animates on its own. Slices borrow the
/// payload.
pub const Post = struct {
animation: ShaderAnimation = .on,
- passes: [shader_build.max]shader_build.Pass = @splat(.{}),
+ passes: [ShaderBuild.max]ShaderBuild.Pass = @splat(.{}),
len: u8 = 0,
- pub fn list(p: *const Post) []const shader_build.Pass {
+ pub fn list(p: *const Post) []const ShaderBuild.Pass {
return p.passes[0..p.len];
}
};
@@ -1275,7 +1275,7 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg {
.post => blk: {
var p: Post = .{ .animation = try r.getTag(ShaderAnimation) };
p.len = try r.getByte();
- if (p.len > shader_build.max) return error.Overlong;
+ if (p.len > ShaderBuild.max) return error.Overlong;
for (p.passes[0..p.len]) |*pass| {
const kind = try r.getByte();
pass.* = .{
diff --git a/src/draw.zig b/src/draw.zig
index 9c9e11dd..ad24153f 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -63,7 +63,7 @@ pub fn place(p: *Pardes, s: *Surface) void {
// A folded pane is its tag rows and nothing else.
if (pane.collapsed) continue;
const body_h = r.h -| pane.tag_rows;
- const line: i32 = if (pane.file) |f| @intCast(@min(f.scroll, std.math.maxInt(i32))) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0;
+ const line: i32 = if (pane.file) |f| @intCast(@min(f.scroll, std.math.maxInt(i32))) else if (pane.isTerminal()) panes.terminal.gridOffset(pane) else 0;
s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .line = line, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } });
var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } };
// An image's rail has no thumb; a native PDF's is measured as its
@@ -78,7 +78,7 @@ pub fn place(p: *Pardes, s: *Surface) void {
.offset = page,
.len = 1,
} else blk: {
- const gsb = panes.Terminal.scrollbar(pane);
+ const gsb = panes.terminal.scrollbar(pane);
break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len };
};
const track_h: usize = body_h;
@@ -241,7 +241,7 @@ fn glideCursor(p: *Pardes, s: *Surface) void {
if (inside) frame = paneFrame(s, pane.serial, rect);
const origin: [2]f32 = .{ @floatFromInt(rect.x), @floatFromInt(rect.y) };
const view = p.cursor_view;
- const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0;
+ const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.terminal.gridOffset(pane) else 0;
if (view.serial == pane.serial and view.inside == inside) {
// The pane moved in the layout: the springs move with it (its
// own cursor's; one in a column's tag stays).
@@ -719,7 +719,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
sb_off = page;
sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0;
} else {
- const sb = panes.Terminal.scrollbar(at);
+ const sb = panes.terminal.scrollbar(at);
sb_off = sb.offset;
sb_total = sb.total;
}
@@ -1477,6 +1477,60 @@ test "a grip's four states, focused or not and clean or dirty, are each its own
pane.file.?.revision = pane.file.?.saved_revision;
}
+test "every theme draws a file's name, a +Errors and a Tty word in a tint, never the tag's plain ink" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 120, .rows = 40, .tty_only = true });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ try std.testing.expect(p.executeBuiltinLine(0, "Tty+sh"));
+ const terminal = p.active;
+ p.acknowledgeShell(terminal, "/bin/sh", false);
+ try @import("Output.zig").openErrors(p, 0, try gpa.dupe(u8, "oops\n"));
+ while (p.nextEffect()) |_| {}
+ p.animate_theme_changes = false;
+ const errors = for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ if (pane.file) |f| if (std.mem.endsWith(u8, f.path, "+Errors")) break id;
+ } else return error.NoErrorsPane;
+ const Probe = struct { id: usize, word: []const u8 };
+ const probes = [_]Probe{ .{ .id = 0, .word = "test.txt" }, .{ .id = terminal, .word = "Tty+sh" }, .{ .id = errors, .word = "+Errors" } };
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ for (pardes.themes, 0..) |theme, index| {
+ colors.setThemeIndex(p, index);
+ // Each pane focused in turn: every word on both grounds.
+ for (probes) |focused| {
+ p.active = focused.id;
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ for (probes) |probe| {
+ const r = p.rects[probe.id];
+ const y = p.tagTop(p.panes[probe.id].?, r);
+ // The row's text, and the column each byte of it is drawn at.
+ var row: [512]u8 = undefined;
+ var cols: [512]u16 = undefined;
+ var len: usize = 0;
+ for (r.x..r.x + r.w) |x| {
+ const g = s.at(@intCast(x), y).grapheme();
+ for (if (g.len == 0) " " else g) |byte| if (len < row.len) {
+ row[len] = byte;
+ cols[len] = @intCast(x);
+ len += 1;
+ };
+ }
+ const name = std.mem.indexOf(u8, row[0..len], probe.word) orelse return error.WordNotDrawn;
+ const plain = std.mem.indexOf(u8, row[0..len], " Del") orelse return error.WordNotDrawn;
+ const tint = s.at(cols[name], y).style.fg;
+ const ink = s.at(cols[plain + 1], y).style.fg;
+ if (std.meta.eql(tint, ink)) std.debug.print("{s}: {s} drawn in the tag's ink\n", .{ theme.name, probe.word });
+ try std.testing.expect(!std.meta.eql(tint, ink));
+ // The whole word, in the one tint.
+ try std.testing.expect(std.meta.eql(s.at(cols[name + probe.word.len - 1], y).style.fg, tint));
+ }
+ }
+ }
+}
+
test "a tag of three rows has its grip on the first, and band under it that still grabs the pane" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
defer p.deinit();
diff --git a/src/dump.zig b/src/dump.zig
index fca88a2d..73d4e4ed 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -31,7 +31,8 @@ const Pardes = pardes.Pardes;
/// Where the next dump goes. $PARDES_DUMP wins verbatim (the snapshot harness
/// pins it for deterministic goldens); else $XDG_DATA_HOME|~/.local/share
/// /pardes/pardes-<utc>.zon — timestamped so dumps never overwrite each other.
-/// Creates the pardes dir (parents assumed; a failure surfaces at open).
+/// Creates the directory and any parent missing (a fresh HOME has no
+/// ~/.local/share); a failure surfaces at open.
pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 {
if (std.c.getenv("PARDES_DUMP")) |p|
return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(p)}, 0) catch null;
@@ -40,7 +41,13 @@ pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 {
var dz: [901:0]u8 = undefined;
@memcpy(dz[0..dir.len], dir);
dz[dir.len] = 0;
- _ = std.c.mkdir(dz[0..dir.len :0], 0o755); // EEXIST is fine
+ // Each missing directory on the way, as `mkdir -p`: EEXIST is fine.
+ for (dz[1..dir.len], 1..) |c, i| if (c == '/') {
+ dz[i] = 0;
+ _ = std.c.mkdir(dz[0..i :0], 0o755);
+ dz[i] = '/';
+ };
+ _ = std.c.mkdir(dz[0..dir.len :0], 0o755);
var ts: std.c.timespec = undefined;
_ = std.c.clock_gettime(.REALTIME, &ts);
const es: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, ts.sec)) };
@@ -109,6 +116,21 @@ pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 {
return std.fmt.bufPrint(buf, "{s}", .{resolved}) catch null;
}
+test "a dump makes every directory missing on the way to its DumpDir" {
+ if (std.c.getenv("PARDES_DUMP") != null) return error.SkipZigTest;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var at: [4096]u8 = undefined;
+ const base = at[0..try tmp.dir.realPath(std.testing.io, &at)];
+ var setting: [4200]u8 = undefined;
+ const dir = try std.fmt.bufPrint(&setting, "{s}/fresh/.local/share/pardes", .{base});
+ var out: [1024:0]u8 = undefined;
+ const path = outPath(&out, dir).?;
+ try std.testing.expect(std.mem.startsWith(u8, path, dir));
+ var made = try tmp.dir.openDir(std.testing.io, "fresh/.local/share/pardes", .{});
+ made.close(std.testing.io);
+}
+
test "a DumpDir reads back absolute, however it was written" {
var buf: [4096]u8 = undefined;
try std.testing.expectEqualStrings("/tmp/dumps", directory(&buf, "/tmp/dumps/").?);
@@ -129,7 +151,7 @@ pub fn defaultDirectory(buf: []u8) ?[]const u8 {
pub const magic = "pardes-dump";
pub const version: u32 = 1;
pub const max_panes: usize = MAX_PANES;
-pub const max_cols: usize = 6;
+pub const max_cols: usize = MAX_COLS;
/// Output arguments are typed in the same bounded one-line tag storage. Keep
/// the schema limit named independently so a dump reader can validate it
/// without importing the output-pane implementation. The bound itself is
@@ -256,7 +278,7 @@ pub const State = struct {
topbar: []const u8 = "",
topbar_custom: ?[]const u8 = null,
theme: []const u8 = "dark",
- locations_config: @import("locations_config.zig").Config = .{},
+ locations_config: @import("locations.zig").Config = .{},
tree_context_tag_style: bool = true,
columns: []const Column = &.{},
panes: []const Pane = &.{},
@@ -709,7 +731,7 @@ pub fn dumpState(p: *Pardes) !void {
} else if (pane.image) |iv|
try pardes.panes.Image.dumpPane(p, arena, pane, tag, body, scroll, iv.path, iv.raw)
else
- try pardes.panes.Terminal.dumpPane(pane, arena, tag, body, scroll);
+ try pardes.panes.terminal.dumpPane(pane, arena, tag, body, scroll);
dp.tag_tail = pane.tag.own;
if (dp.file) |*df| {
const dot = ctlfs.pane.dotOf(pane);
@@ -892,7 +914,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
// and how it ended: its command is not run again.
const live = comptime (terminal_panes and platform != .web and !isolated);
const revive = live and t.cwd.len <= effect_path_cap and t.command.len == 0;
- const restored = try pardes.panes.Terminal.restore(p, src, revive);
+ const restored = try pardes.panes.terminal.restore(p, src, revive);
p.installPane(i, restored);
try restored.setOwnedCwd(t.cwd);
if (t.shell.len > 0) restored.shell = try gpa.dupe(u8, t.shell);
@@ -987,6 +1009,24 @@ test "a dump keeps the settings that differ from a fresh session's, and a restor
try std.testing.expect(!restored.settings.verbose);
}
+test "a session with every column it may hold dumps and restores them all" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 });
+ defer p.deinit();
+ while (p.ncol < MAX_COLS) {
+ layout.compute(p);
+ var widest: usize = 0;
+ for (0..p.ncol) |c| if (p.col_w[c] > p.col_w[widest]) {
+ widest = c;
+ };
+ _ = layout.insertColumn(p, widest, false) orelse return error.NoColumn;
+ }
+ try dumpState(p);
+ const restored = try initFromDump(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 }, p.dump_out.?);
+ defer restored.deinit();
+ try std.testing.expectEqual(@as(usize, MAX_COLS), restored.ncol);
+}
+
test "a restored pane keeps its dot, as acme's dump keeps a window's" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
diff --git a/src/edit.zig b/src/edit.zig
index 183d6325..98c60efb 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -17,7 +17,7 @@ const syntax = @import("syntax.zig");
const platform = pardes.platform;
const Pane = panes.Pane;
const Text = panes.Text;
-const tag_layer = @import("tag_layer.zig");
+const Layer = @import("Layer.zig");
const TagHit = pardes.TagHit;
const BOX_H = pardes.BOX_H;
const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
@@ -113,7 +113,7 @@ pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode"))
pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void {
if (text.len == 0) return;
- if (panes.Terminal.bracketedPaste(pane)) {
+ if (panes.terminal.bracketedPaste(pane)) {
p.emitWrite(id, "\x1b[200~");
p.emitWrite(id, text);
p.emitWrite(id, "\x1b[201~");
@@ -225,7 +225,7 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
// Refresh the source map before translating the rendered rectangle.
const raw = pane.isTerminal() and pane.body.mode == .tty;
const body = try body_layer.bodyText(p, p.scratch.allocator(), pane);
- const terminal_lines = if (pane.file == null and !raw) try panes.Terminal.cursorLines(p, pane) else &.{};
+ const terminal_lines = if (pane.file == null and !raw) try panes.terminal.cursorLines(p, pane) else &.{};
const first = @max(0, @min(gesture.r0, gesture.r1) - pane.tag_rows);
const logical_rows = if (pane.body_rows > 0) pane.body_rows else pane.rows;
const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - pane.tag_rows);
@@ -242,7 +242,7 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
const left: i32 = if (cols.first) cols.lo else prefix;
const right: i32 = cols.hi orelse std.math.maxInt(i32) / 2;
var source = pane.wrapAt(visible);
- if (raw) source = .{ .line = panes.Terminal.gridOffset(pane) + visible, .at = 0 };
+ if (raw) source = .{ .line = panes.terminal.gridOffset(pane) + visible, .at = 0 };
const line = if (raw) modal.lineSlice(body, @intCast(visible)) else pointerSourceLine(pane, terminal_lines, source.line);
var next = pane.wrapAt(visible + 1);
if (raw) next = .{ .line = source.line + 1, .at = 0 };
@@ -256,7 +256,7 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
.hi = if (right < prefix) lo else if (cols.hi == null) end else @min(end, modal.nextGrapheme(line, at)),
.to_edge = cols.hi == null,
.raw_terminal = raw,
- .prompt_bytes = if (raw) panes.Terminal.promptPrefixBytes(pane, source.line, line) else 0,
+ .prompt_bytes = if (raw) panes.terminal.promptPrefixBytes(pane, source.line, line) else 0,
};
const rendered = modal.lineSlice(body, @intCast(visible));
const prefix_end = @min(rendered.len, @as(usize, @intCast(@min(prefix, right + 1))));
@@ -276,7 +276,7 @@ pub fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 {
const arena = p.scratch.allocator();
if (pane.rawPointerText(sl)) |text| return arena.dupe(u8, text);
if (pane.pointerSelection(sl)) |rows| {
- const terminal_lines = if (pane.file == null) try panes.Terminal.cursorLines(p, pane) else &.{};
+ const terminal_lines = if (pane.file == null) try panes.terminal.cursorLines(p, pane) else &.{};
var total: usize = rows.len -| 1;
for (rows) |row| {
const line = pointerSourceLine(pane, terminal_lines, row.row);
@@ -402,7 +402,7 @@ pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 {
if (comptime pdf_enabled) return panes.Pdf.textLines(&pane.pdf.?, p.pdf_gpa, arena);
unreachable;
}
- return panes.Terminal.cursorLines(p, pane);
+ return panes.terminal.cursorLines(p, pane);
}
pub fn paneByteAtDisplay(p: *Pardes, t: *Text, row: i32, from_raw: i32, display_col: i32) i32 {
@@ -448,8 +448,8 @@ pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 {
if (comptime pdf_enabled) return pane.pdf.?.ensureText(p.pdf_gpa);
unreachable;
}
- const lines = try panes.Terminal.cursorLines(p, pane);
- return panes.Terminal.flatSurface(p, pane, lines);
+ const lines = try panes.terminal.cursorLines(p, pane);
+ return panes.terminal.flatSurface(p, pane, lines);
}
pub fn paneWrapWidth(p: *const Pardes, t: *Text) usize {
@@ -474,7 +474,7 @@ fn editText(p: *Pardes, t: *Text, lo: i32, hi: i32, col: i32) ?panes.EditText {
const pane = t.pane() orelse return null;
if (pane.file) |f| return .{ .text = f.content, .row0 = 0 };
if (pane.image != null or pane.hasPdf()) return null;
- return panes.Terminal.editText(p, pane, lo, hi, col);
+ return panes.terminal.editText(p, pane, lo, hi, col);
}
fn editTextEol(p: *Pardes, t: *Text, b: Bounds) ?panes.EditText {
@@ -535,7 +535,7 @@ pub fn setEditTextEnd(p: *Pardes, t: *Text, new: []u8, known_end: ?modal.Cursor)
};
t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) };
if (pane.file) |*f| return panes.File.setContent(p, f, new);
- panes.Terminal.setEditText(p, pane, new);
+ panes.terminal.setEditText(p, pane, new);
}
/// Take a text an edit made that nothing keeps, holding it until the next
@@ -2057,7 +2057,7 @@ pub fn pushUndo(p: *Pardes, t: *Text) void {
}
const pane = t.pane() orelse return;
if (pane.file != null) return panes.File.pushUndo(p, pane);
- panes.Terminal.pushUndo(p, pane);
+ panes.terminal.pushUndo(p, pane);
}
pub fn doUndo(p: *Pardes, t: *Text) void {
@@ -2065,7 +2065,7 @@ pub fn doUndo(p: *Pardes, t: *Text) void {
if (t.what != .body) return;
const pane = t.pane() orelse return;
if (pane.file != null) return panes.File.undo(p, pane);
- panes.Terminal.undo(p, pane);
+ panes.terminal.undo(p, pane);
}
pub fn doRedo(p: *Pardes, t: *Text) void {
@@ -2073,7 +2073,7 @@ pub fn doRedo(p: *Pardes, t: *Text) void {
if (t.what != .body) return;
const pane = t.pane() orelse return;
if (pane.file != null) return panes.File.redo(p, pane);
- panes.Terminal.redo(p, pane);
+ panes.terminal.redo(p, pane);
}
/// Undo or redo an edit of a text's own characters; stepping back past its
@@ -2108,7 +2108,7 @@ pub fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, bod
const v = @as(i32, mapped_hit.row) + pane.tag_rows;
return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = false };
};
- const tag_col = if (on_tag) tag_layer.columnAt(p, .pane, id, tag_hit, false, row - tag_y) else null;
+ const tag_col = if (on_tag) Layer.columnAt(p, .pane, id, tag_hit, false, row - tag_y) else null;
const c: i32 = (if (tag_col) |value| @as(i32, value) else @as(i32, col) - @as(i32, r.x + (if (on_tag) TAG_TEXT_INSET else config.GUTTER))) +
(if (on_tag) @as(i32, pane.tag_scroll) else 0);
const v: i32 = if (on_tag) @as(i32, row) - @as(i32, tag_y) else @as(i32, row) - @as(i32, body_y) + @as(i32, pane.tag_rows);
diff --git a/src/exec.zig b/src/exec.zig
index 2c42389c..221de105 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -42,11 +42,11 @@ pub fn takesCommandLine(p: *const Pardes, id: usize) bool {
// idle, EMPTY prompt is typed at; otherwise a new pane runs the line.
// (Text in pardes's own edit buffer is not the shell's yet: an exec of
// it, or of anything, types only what it runs.)
- if (panes.Terminal.promptInputReady(pane) and !panes.Terminal.promptInputEmpty(pane)) return false;
+ if (panes.terminal.promptInputReady(pane) and !panes.terminal.promptInputEmpty(pane)) return false;
// A mounted shell sits behind sudo's process supervisor. Its OSC 133
// prompt marks identify input readiness; the launcher's executable is
// not the shell executable used by the ordinary process heuristic.
- if (pane.v9fs_on_spawn) return panes.Terminal.promptInputReady(pane);
+ if (pane.v9fs_on_spawn) return panes.terminal.promptInputReady(pane);
return !p.hostTtyTaken(id);
}
@@ -111,7 +111,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand
const visible = clicked.r0 - @as(i32, pane.tag_rows);
const wrapped = pane.wrapAt(visible);
const raw = pane.isTerminal() and pane.body.mode == .tty and clicked.r0 >= pane.tag_rows;
- const row = if (raw) panes.Terminal.gridOffset(pane) + visible else wrapped.line;
+ const row = if (raw) panes.terminal.gridOffset(pane) + visible else wrapped.line;
const raw_line = if (raw) modal.lineSlice(body_layer.bodyText(p, p.scratch.allocator(), pane) catch "", @intCast(@max(0, visible))) else "";
const col = if (raw)
@as(i32, @intCast(panes.File.rawAtDisplay(raw_line, @intCast(@max(0, clicked.c0)))))
@@ -143,7 +143,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand
lo -|= span.prompt_bytes;
hi -|= span.prompt_bytes;
} else if (!span.raw_terminal and raw) {
- const prefix = panes.Terminal.promptPrefixBytes(pane, row, raw_line);
+ const prefix = panes.terminal.promptPrefixBytes(pane, row, raw_line);
lo += prefix;
hi += prefix;
}
@@ -429,8 +429,8 @@ pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void {
const tt = p.panes[tty_id] orelse return;
// No typing, cursor on the first prompt line, no scrollback: this is
// the throwaway boot placeholder a document may replace.
- if (tt.ovl != null or panes.Terminal.gridCursor(tt).y != 0 or
- panes.Terminal.scrollbar(tt).total > tt.rows) return;
+ if (tt.ovl != null or panes.terminal.gridCursor(tt).y != 0 or
+ panes.terminal.scrollbar(tt).total > tt.rows) return;
p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err);
layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows
_ = layout.absorbVWeight(p, tty_id, null);
@@ -588,7 +588,7 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void {
var said: [128]u8 = undefined;
return p.setMessage(from, std.fmt.bufPrint(&said, "Repl: {d} bytes is more than can be sent at once; send less", .{n}) catch "Repl: too long to send");
}
- const pasted = panes.Terminal.bracketedPaste(dst);
+ const pasted = panes.terminal.bracketedPaste(dst);
edit.typeToTty(p, to, dst, clean[0..n]);
// A Python block whose last line is indented, or a bare header, is not
// over at one Enter, pasted into 3.13's REPL or typed into the old one:
@@ -611,7 +611,7 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void {
dst.repl_enter_due_ns = clockNow(p) + repl_enter_wait_ns;
} else for (0..enters) |_| p.emitWrite(to, "\r");
// Line by line, a blank line inside a Python block ends the block.
- if (!panes.Terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and
+ if (!panes.terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and
std.mem.indexOfScalar(u8, text, '\n') != null)
{
dst.repl_warned = true;
@@ -671,9 +671,9 @@ pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usi
return null;
}
if (!takesCommandLine(p, id)) return runCommand(p, id, cmd);
- panes.Terminal.padOutputBelowEdits(p, id);
- panes.Terminal.noteCommand(pane, cmd);
- if (panes.Terminal.queuePendingCommand(pane, cmd) catch |err| {
+ panes.terminal.padOutputBelowEdits(p, id);
+ panes.terminal.noteCommand(pane, cmd);
+ if (panes.terminal.queuePendingCommand(pane, cmd) catch |err| {
p.reportError(id, "queue command", err);
return id;
}) return id;
@@ -733,8 +733,8 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
// saved cursor), mouse reports and bracketed paste off, the cursor
// shown, colours reset. (Not DECSTR: ghostty's stream does not
// implement it.)
- if (panes.Terminal.onAlternateScreen(pane)) panes.Terminal.feedOutput(p, pane, "\x1b[?1049l");
- panes.Terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m");
+ if (panes.terminal.onAlternateScreen(pane)) panes.terminal.feedOutput(p, pane, "\x1b[?1049l");
+ panes.terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m");
echoCommand(p, pane, line);
p.emitSpawn(id, pane.serial, pane.cwdSlice());
noteRun(p, pane, "run", line);
@@ -760,10 +760,10 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
fn echoCommand(p: *Pardes, pane: *Pane, line: []const u8) void {
var buf: [2 * command_max + 8]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
- w.writeAll(if (panes.Terminal.gridCursor(pane).x != 0) "\r\n% " else "% ") catch {};
+ w.writeAll(if (panes.terminal.gridCursor(pane).x != 0) "\r\n% " else "% ") catch {};
for (line) |c| (if (c == '\n') w.writeAll("\r\n") else w.writeByte(c)) catch {};
w.writeAll("\r\n") catch {};
- panes.Terminal.feedOutput(p, pane, w.buffered());
+ panes.terminal.feedOutput(p, pane, w.buffered());
}
/// `run <serial> <word>` or `exit <serial> <N|?>` in the log.
@@ -788,8 +788,8 @@ pub fn commandDone(p: *Pardes, id: usize, status: ?u8) void {
var buf: [16]u8 = undefined;
const code = if (status) |n| std.fmt.bufPrint(&buf, "{d}", .{n}) catch "?" else "?";
var said: [32]u8 = undefined;
- const lead = if (panes.Terminal.gridCursor(pane).x != 0) "\r\n" else "";
- panes.Terminal.feedOutput(p, pane, std.fmt.bufPrint(&said, "{s}exit {s}\r\n", .{ lead, code }) catch "exit ?\r\n");
+ const lead = if (panes.terminal.gridCursor(pane).x != 0) "\r\n" else "";
+ panes.terminal.feedOutput(p, pane, std.fmt.bufPrint(&said, "{s}exit {s}\r\n", .{ lead, code }) catch "exit ?\r\n");
noteRun(p, pane, "exit", code);
p.needs_frame = true;
}
diff --git a/src/file_watch.zig b/src/file_watch.zig
index 148bc836..6c746e74 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -70,7 +70,7 @@ pub const Watch = struct {
/// Sharing the table also shares directory descriptors correctly when a user
/// happens to open that .zon as an ordinary document.
pub const theme_slot = pardes.MAX_PANES;
-/// After it, one slot a post chain file (shader_build.zig): marked only so
+/// After it, one slot a post chain file (ShaderBuild.zig): marked only so
/// a save there wakes the host, which has the files read again; no
/// generation of theirs is kept here.
pub const shader_slot = theme_slot + 1;
diff --git a/src/fs.zig b/src/fs.zig
index edead0ff..fc3eb98f 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -55,9 +55,9 @@ const source_files = [_]Source{
.{ .path = "src/Pipe.zig", .contents = @embedFile("Pipe.zig") },
.{ .path = "src/File.zig", .contents = @embedFile("File.zig") },
.{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") },
- .{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") },
+ .{ .path = "src/mini.zig", .contents = @embedFile("mini.zig") },
.{ .path = "src/image.zig", .contents = @embedFile("image.zig") },
- .{ .path = "src/Terminal.zig", .contents = @embedFile("Terminal.zig") },
+ .{ .path = "src/terminal.zig", .contents = @embedFile("terminal.zig") },
.{ .path = "src/pdf_view.zig", .contents = @embedFile("pdf_view.zig") },
.{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") },
.{ .path = "src/fs.zig", .contents = @embedFile("fs.zig") },
diff --git a/src/gui/Post.zig b/src/gui/Post.zig
index 602b2790..fc256135 100644
--- a/src/gui/Post.zig
+++ b/src/gui/Post.zig
@@ -2,7 +2,7 @@
//! finished frame, each reading the one before it (the frame first) and the
//! last one writing the window. A pass is the bundled Crt (shaders/post/,
//! compiled with the build) or a user's file (Shader <path>), whose SPIR-V
-//! comes from shader_build.zig: compiled by the process that holds the core,
+//! comes from ShaderBuild.zig: compiled by the process that holds the core,
//! this one or the detached session this GUI is attached to. A file whose
//! compile fails keeps its last good pipeline.
//!
@@ -24,7 +24,7 @@ const Pipeline = c.SDL_GPUGraphicsPipeline;
pub const max = Chain.max;
const prefix = @embedFile("post-prefix.glsl");
-const Built = @import("../shader_build.zig").Pass;
+const Built = @import("../ShaderBuild.zig").Pass;
const vert_spv = @embedFile("post.vert.spv");
const bundled_spv = std.EnumArray(Scene, []const u8).init(.{
.crt = @embedFile("post-crt.frag.spv"),
@@ -92,9 +92,9 @@ pub const Pass = struct {
/// A user's file, owned; empty for a bundled pass.
path: []u8 = &.{},
pipeline: ?*Pipeline = null,
- /// The SPIR-V revision `pipeline` was made from (shader_build.Pass).
+ /// The SPIR-V revision `pipeline` was made from (ShaderBuild.Pass).
revision: u32 = 0,
- /// It moves on its own (shader_build.Pass).
+ /// It moves on its own (ShaderBuild.Pass).
animated: bool = false,
};
@@ -125,7 +125,7 @@ bloom_up: ?*Pipeline = null,
started_ns: ?u64 = null,
last_ns: u64 = 0,
-/// Brings the passes to the chain a shell was given (shader_build.zig: a
+/// Brings the passes to the chain a shell was given (ShaderBuild.zig: a
/// local core's, or an attached session's over the wire), whose `key` moves
/// whenever the chain or a file's SPIR-V does. A file whose SPIR-V has not
/// moved keeps its pipeline; one the GPU refuses keeps its last, and is said
@@ -203,7 +203,7 @@ pub fn ready(post: *const Post) bool {
}
/// The chain redraws on its own (level A) as ShaderAnimation says.
-/// Only a pass that moves on its own (shader_build.Pass.animated) asks for
+/// Only a pass that moves on its own (ShaderBuild.Pass.animated) asks for
/// frames while idle: a still one (Bloom, Vignette, Grain, a file that reads
/// no clock) is drawn again only with a frame of the core's.
pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) bool {
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index b34b314d..df565b94 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -11,7 +11,7 @@ const config = @import("../config.zig");
const look = @import("../look.zig");
const message = pardes.Messages.Message;
const file_watch = @import("../file_watch.zig");
-const shader_build = @import("../shader_build.zig");
+const ShaderBuild = @import("../ShaderBuild.zig");
const deck = @import("deck.zig");
const Post = @import("Post.zig");
const pet = @import("pet.zig");
@@ -4016,7 +4016,7 @@ const Shell = struct {
inotify_fd: c_int,
watches: *file_watch.Table,
/// The post chain's files as SPIR-V.
- shaders: shader_build = .{},
+ shaders: ShaderBuild = .{},
threads_ok: bool = false,
test_mode: bool = false,
feed: StdinFeed = .{},
@@ -4240,7 +4240,7 @@ const Shell = struct {
}
};
-/// A post chain compile is done (shader_build.zig): the loop's SDL wait ends.
+/// A post chain compile is done (ShaderBuild.zig): the loop's SDL wait ends.
fn wakeSdl(_: ?*anyopaque) void {
var event = std.mem.zeroes(c.SDL_Event);
event.type = c.SDL_EVENT_USER;
@@ -4391,7 +4391,7 @@ fn pollFrame(ctx: ?*anyopaque) void {
stepScroll(g, core, s.gpa);
_ = s.shaders.sync(s.gpa, s.io, core, s.inotify_fd, s.watches, .{ .call = wakeSdl });
{
- var passes: [Post.max]shader_build.Pass = undefined;
+ var passes: [Post.max]ShaderBuild.Pass = undefined;
g.post.sync(s.gpa, g.device, g.swapchain_format, s.shaders.view(&core.settings.post, &passes), s.shaders.revision, core);
}
if (s.test_mode) writeTestStatus(g, core, s.presented);
diff --git a/src/host_io.zig b/src/host_io.zig
index eb6d1d5d..05ccca09 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -1293,7 +1293,7 @@ pub fn forkShell(
var command: std.Io.Writer.Allocating = .init(c.gpa);
defer command.deinit();
try @import("linux/v9fs.zig").writeLaunchCommand(&command.writer, path, std.mem.span(socket), &spawn.argv);
- if (!try pardes.panes.Terminal.queuePendingCommand(pn, command.written())) return error.ShellAlreadyStarted;
+ if (!try pardes.panes.terminal.queuePendingCommand(pn, command.written())) return error.ShellAlreadyStarted;
}
// Built here and not after the fork: between fork and exec the child may
// not call setenv, whose malloc can deadlock against a pty reader thread
diff --git a/src/layout.zig b/src/layout.zig
index 28f567bc..28f3d270 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -607,7 +607,7 @@ fn blankRows(p: *const Pardes, id: usize) u16 {
// 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
+ @as(usize, panes.terminal.gridCursor(pane).y) + 1
else
rows;
return rows -| @as(u16, @intCast(@min(used, rows)));
@@ -742,7 +742,7 @@ pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void {
// Its tag may wrap to more than one row: the rows under it are its body.
const tag_h = @max(BOX_H, src.tag_rows);
const body: u16 = if (src_h > tag_h) src_h - tag_h else 1;
- const cur: u16 = if (!src.isTerminal()) body / 2 else panes.Terminal.gridCursor(src).y + 1;
+ const cur: u16 = if (!src.isTerminal()) body / 2 else panes.terminal.gridCursor(src).y + 1;
// cap keep so a content-full source still leaves the new pane a tag +
// a few body rows (an Alt-n from a full shell was born 0 rows tall)
// ...and the source min_body_rows of its own (exec.placeNew), when it
diff --git a/src/locations.zig b/src/locations.zig
index b34f7a6f..6a604907 100644
--- a/src/locations.zig
+++ b/src/locations.zig
@@ -4,7 +4,6 @@ const look = @import("look.zig");
const syntax = @import("syntax.zig");
const filesystem = @import("fs.zig");
-pub const Config = @import("locations_config.zig").Config;
const neighbor_context: u8 = 1;
const declaration_context: u8 = 2;
const declaration_start: u8 = 4;
@@ -206,7 +205,7 @@ pub fn format(p: *pardes.Pardes, dir: []const u8, input: []const u8, anchor: ?us
// Muted declaration headers do not need a source-color pass.
// Exact bytes include unsaved edits and virtual/remote sources.
var cached = p.locations_cache.get(p.tree_sitter_gpa, text.path, source, p.locations_config.tscontext, p.locations_config.context > 0) catch
- @import("locations_cache.zig").Result{ .analysis = syntax.analyzeSource(arena, text.path, source, p.locations_config.tscontext, p.locations_config.context > 0) catch .{} };
+ Cache.Hit{ .analysis = syntax.analyzeSource(arena, text.path, source, p.locations_config.tscontext, p.locations_config.context > 0) catch .{} };
defer cached.deinit(p.tree_sitter_gpa);
const analysis = cached.analysis;
const source_colors = analysis.colors;
@@ -393,3 +392,292 @@ test "locations native results retain the first source tab" {
try std.testing.expectEqualStrings("some directory/a.zig", row.path);
try std.testing.expectEqualStrings("\t value", text[row.code_start..]);
}
+
+// ---- the Locations setting ----
+
+pub const Config = struct {
+ pub const Layout = enum { @"inline", stacked };
+ context: u16 = 0,
+ tscontext: bool = false,
+ tslocations: bool = true,
+ layout: Layout = .stacked,
+
+ /// Apply only supplied fields. A malformed token rejects the whole update.
+ /// Repeating a field uses its last supplied value.
+ pub fn parse(current: Config, text: []const u8) !Config {
+ var result = current;
+ var tokens = std.mem.tokenizeAny(u8, text, " \t\r\n");
+ while (tokens.next()) |token| {
+ const colon = std.mem.indexOfScalar(u8, token, ':') orelse return error.ExpectedKeyValue;
+ const key = token[0..colon];
+ const value = token[colon + 1 ..];
+ var found = false;
+ inline for (@typeInfo(Config).@"struct".fields) |field| {
+ if (std.mem.eql(u8, key, field.name)) {
+ @field(result, field.name) = switch (@typeInfo(field.type)) {
+ .bool => if (std.mem.eql(u8, value, "on")) true else if (std.mem.eql(u8, value, "off")) false else return error.ExpectedOnOrOff,
+ .int => std.fmt.parseInt(field.type, value, 10) catch return error.InvalidContext,
+ .@"enum" => std.meta.stringToEnum(field.type, value) orelse return error.InvalidLayout,
+ else => @compileError("unsupported location setting type"),
+ };
+ found = true;
+ }
+ }
+ if (!found) return error.UnknownLocationSetting;
+ }
+ return result;
+ }
+
+ pub fn write(config: Config, writer: *std.Io.Writer) !void {
+ inline for (@typeInfo(Config).@"struct".fields, 0..) |field, index| {
+ if (index > 0) try writer.writeByte(' ');
+ try writer.writeAll(field.name ++ ":");
+ const value = @field(config, field.name);
+ switch (@typeInfo(field.type)) {
+ .bool => try writer.writeAll(if (value) "on" else "off"),
+ .int => try writer.print("{d}", .{value}),
+ .@"enum" => try writer.writeAll(@tagName(value)),
+ else => @compileError("unsupported location setting type"),
+ }
+ }
+ }
+};
+
+test "LocationsConfig reflection round trip and partial updates" {
+ var config: Config = .{};
+ config = try config.parse("context:5 tscontext:on");
+ config = try config.parse("tslocations:off");
+ try std.testing.expectEqual(Config{ .context = 5, .tscontext = true, .tslocations = false }, config);
+ var out: std.Io.Writer.Allocating = .init(std.testing.allocator);
+ defer out.deinit();
+ try config.write(&out.writer);
+ try std.testing.expectEqualStrings("context:5 tscontext:on tslocations:off layout:stacked", out.written());
+ try std.testing.expectEqual(config, try (Config{}).parse(out.written()));
+ try std.testing.expectEqual(config, try config.parse(" \n\t"));
+ try std.testing.expectEqual(@as(u16, 2), (try config.parse("context:1 context:2")).context);
+ try std.testing.expectEqual(Config.Layout.@"inline", (try config.parse("layout:inline")).layout);
+}
+
+test "LocationsConfig rejects invalid updates atomically" {
+ const config: Config = .{ .context = 3, .tscontext = true };
+ try std.testing.expectError(error.UnknownLocationSetting, config.parse("context:5 unknown:on"));
+ try std.testing.expectError(error.ExpectedOnOrOff, config.parse("context:5 tscontext:true"));
+ try std.testing.expectError(error.ExpectedKeyValue, config.parse("context"));
+ try std.testing.expectError(error.InvalidLayout, config.parse("context:5 layout:sideways"));
+ for ([_][]const u8{ "context:", "context:-1", "context:65536", "context:five" }) |input|
+ try std.testing.expectError(error.InvalidContext, config.parse(input));
+ try std.testing.expectEqual(@as(u16, 3), config.context);
+}
+
+// ---- the analysis cache ----
+
+/// Owned source snapshots keep analysis valid across filesystem changes and
+/// borrowed editor buffers. Entries are ordered from least to most recent.
+pub const Cache = struct {
+ /// A cache hit borrows its analysis; an entry too large to retain transfers
+ /// ownership instead. Call deinit after consuming either result.
+ pub const Hit = struct {
+ analysis: syntax.SourceAnalysis,
+ owned: bool = false,
+
+ pub fn deinit(result: *Hit, gpa: std.mem.Allocator) void {
+ if (result.owned) result.analysis.deinit(gpa);
+ result.* = undefined;
+ }
+ };
+
+ pub const max_bytes = 64 * 1024 * 1024;
+ pub const max_entries = 64;
+
+ const Entry = struct {
+ path: []u8,
+ source: []u8,
+ analysis: syntax.SourceAnalysis,
+ declarations_ready: bool,
+ colors_ready: bool,
+
+ fn size(entry: Entry) usize {
+ return entry.path.len + entry.source.len + entry.analysis.colors.len +
+ entry.analysis.declarations.len * @sizeOf(syntax.ContextDeclaration);
+ }
+
+ fn deinit(entry: *Entry, gpa: std.mem.Allocator) void {
+ gpa.free(entry.path);
+ gpa.free(entry.source);
+ entry.analysis.deinit(gpa);
+ }
+ };
+
+ entries: [max_entries]Entry = undefined,
+ len: usize = 0,
+ bytes: usize = 0,
+ analyses: usize = 0,
+ hits: usize = 0,
+
+ pub fn deinit(cache: *Cache, gpa: std.mem.Allocator) void {
+ for (cache.entries[0..cache.len]) |*entry| entry.deinit(gpa);
+ cache.* = .{};
+ }
+
+ /// Borrowed slices remain valid until the next get or cache deinit; an
+ /// owned result remains valid until Hit.deinit. All calls for this
+ /// cache must use the same allocator. Unsupported languages
+ /// are cached too, including their empty analysis arrays.
+ pub fn get(cache: *Cache, gpa: std.mem.Allocator, path: []const u8, source: []const u8, want_declarations: bool, want_colors: bool) !Hit {
+ return cache.getBounded(gpa, path, source, want_declarations, want_colors, max_bytes);
+ }
+
+ fn getBounded(cache: *Cache, gpa: std.mem.Allocator, path: []const u8, source: []const u8, want_declarations: bool, want_colors: bool, budget: usize) !Hit {
+ if (source.len > budget or path.len > budget - source.len) return error.SourceTooLarge;
+ var previous: ?usize = null;
+ var declarations = want_declarations;
+ var colors = want_colors;
+ for (cache.entries[0..cache.len], 0..) |entry, index| {
+ if (!std.mem.eql(u8, entry.path, path)) continue;
+ previous = index;
+ if (!std.mem.eql(u8, entry.source, source)) break;
+ if ((!want_declarations or entry.declarations_ready) and (!want_colors or entry.colors_ready)) {
+ // Moving the small ownership record keeps the underlying
+ // allocations intact and avoids timestamp/overflow state.
+ std.mem.copyForwards(Entry, cache.entries[index .. cache.len - 1], cache.entries[index + 1 .. cache.len]);
+ cache.entries[cache.len - 1] = entry;
+ cache.hits +|= 1;
+ return .{ .analysis = entry.analysis };
+ }
+ declarations = declarations or entry.declarations_ready;
+ colors = colors or entry.colors_ready;
+ break;
+ }
+ // A supported full-source color map needs one byte per source byte.
+ // Reject that known minimum before parsing, so the caller's uncached
+ // path does not repeat an expensive analysis for oversized inputs.
+ const input_bytes = path.len + source.len;
+ if (colors and syntax.supportsPath(path) and source.len > budget - input_bytes) return error.SourceTooLarge;
+ var analysis = try syntax.analyzeSource(gpa, path, source, declarations, colors);
+ errdefer analysis.deinit(gpa);
+ cache.analyses +|= 1;
+ const analysis_bytes = analysis.colors.len + analysis.declarations.len * @sizeOf(syntax.ContextDeclaration);
+ if (analysis_bytes > budget - input_bytes) return .{ .analysis = analysis, .owned = true };
+ const owned_path = try gpa.dupe(u8, path);
+ errdefer gpa.free(owned_path);
+ const owned_source = try gpa.dupe(u8, source);
+ errdefer gpa.free(owned_source);
+ const next: Entry = .{
+ .path = owned_path,
+ .source = owned_source,
+ .analysis = analysis,
+ .declarations_ready = declarations,
+ .colors_ready = colors,
+ };
+ // Commit only after every allocation succeeds. A failed refresh leaves
+ // the previous cached snapshot usable by the next request.
+ if (previous) |index| cache.remove(gpa, index);
+ while (cache.len == max_entries or cache.bytes > budget - next.size()) cache.remove(gpa, 0);
+ cache.entries[cache.len] = next;
+ cache.len += 1;
+ cache.bytes += next.size();
+ return .{ .analysis = next.analysis };
+ }
+
+ fn remove(cache: *Cache, gpa: std.mem.Allocator, index: usize) void {
+ cache.bytes -= cache.entries[index].size();
+ cache.entries[index].deinit(gpa);
+ std.mem.copyForwards(Entry, cache.entries[index .. cache.len - 1], cache.entries[index + 1 .. cache.len]);
+ cache.len -= 1;
+ }
+};
+
+test "locations cache validates exact source and path and upgrades analysis" {
+ const gpa = std.testing.allocator;
+ syntax.start(gpa);
+ defer syntax.stop();
+ var cache: Cache = .{};
+ defer cache.deinit(gpa);
+ const source = "const Thing = struct {\n value: u32,\n};\n";
+ _ = try cache.get(gpa, "a.zig", source, true, false);
+ try std.testing.expectEqual(@as(usize, 1), cache.analyses);
+ _ = try cache.get(gpa, "a.zig", source, true, false);
+ try std.testing.expectEqual(@as(usize, 1), cache.analyses);
+ try std.testing.expectEqual(@as(usize, 1), cache.hits);
+ _ = try cache.get(gpa, "a.zig", source, false, true);
+ try std.testing.expectEqual(@as(usize, 2), cache.analyses);
+ try std.testing.expect(cache.entries[0].declarations_ready and cache.entries[0].colors_ready);
+ _ = try cache.get(gpa, "a.zig", source, true, true);
+ try std.testing.expectEqual(@as(usize, 2), cache.analyses);
+ _ = try cache.get(gpa, "a.zig", "const Thing = struct {\n other: u32,\n};\n", true, true);
+ try std.testing.expectEqual(@as(usize, 3), cache.analyses);
+ try std.testing.expectEqual(@as(usize, 1), cache.len);
+ _ = try cache.get(gpa, "a.txt", source, true, true);
+ try std.testing.expectEqual(@as(usize, 4), cache.analyses);
+ try std.testing.expectEqual(@as(usize, 2), cache.len);
+}
+
+test "locations cache bounds memory and entries and evicts least recently used" {
+ const gpa = std.testing.allocator;
+ var cache: Cache = .{};
+ defer cache.deinit(gpa);
+ for (0..Cache.max_entries) |index| {
+ var path: [32]u8 = undefined;
+ _ = try cache.get(gpa, try std.fmt.bufPrint(&path, "{d}.unknown", .{index}), "source", false, false);
+ }
+ _ = try cache.get(gpa, "0.unknown", "source", false, false);
+ _ = try cache.get(gpa, "new.unknown", "source", false, false);
+ try std.testing.expectEqual(Cache.max_entries, cache.len);
+ try std.testing.expectEqualStrings("2.unknown", cache.entries[0].path);
+ const count = cache.analyses;
+ _ = try cache.get(gpa, "0.unknown", "source", false, false);
+ try std.testing.expectEqual(count, cache.analyses);
+ cache.deinit(gpa);
+ _ = try cache.getBounded(gpa, "a.unknown", "source", false, false, 40);
+ _ = try cache.getBounded(gpa, "b.unknown", "source", false, false, 40);
+ _ = try cache.getBounded(gpa, "c.unknown", "source", false, false, 40);
+ try std.testing.expectEqual(@as(usize, 2), cache.len);
+ try std.testing.expect(cache.bytes <= 40);
+ try std.testing.expectEqualStrings("b.unknown", cache.entries[0].path);
+ try std.testing.expectError(error.SourceTooLarge, cache.getBounded(gpa, "large.unknown", "x" ** 41, false, false, 40));
+ try std.testing.expectEqual(@as(usize, 2), cache.len);
+ if (syntax.supportsPath("large.zig")) {
+ const before = cache.analyses;
+ try std.testing.expectError(error.SourceTooLarge, cache.getBounded(gpa, "large.zig", "x" ** 25, true, true, 40));
+ try std.testing.expectEqual(before, cache.analyses);
+ }
+ cache.deinit(gpa);
+ try std.testing.expectEqual(@as(usize, 0), cache.bytes);
+ try std.testing.expectEqual(@as(usize, 0), cache.len);
+}
+
+test "locations cache keeps its previous snapshot after allocation failure" {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const gpa = failing.allocator();
+ var cache: Cache = .{};
+ defer cache.deinit(gpa);
+ _ = try cache.get(gpa, "a.unknown", "old bytes", false, false);
+ const retained = cache.bytes;
+ failing.fail_index = failing.alloc_index;
+ try std.testing.expectError(error.OutOfMemory, cache.get(gpa, "a.unknown", "new bytes", false, false));
+ try std.testing.expectEqual(@as(usize, 1), cache.len);
+ try std.testing.expectEqual(retained, cache.bytes);
+ try std.testing.expectEqualStrings("old bytes", cache.entries[0].source);
+ // This succeeds with allocations still disabled because the old entry
+ // remains valid and the hit path moves ownership records only.
+ _ = try cache.get(gpa, "a.unknown", "old bytes", false, false);
+ try std.testing.expectEqual(@as(usize, 1), cache.hits);
+}
+
+test "locations cache transfers oversized completed analysis without reparsing" {
+ if (!syntax.supportsPath("large.zig")) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ syntax.start(gpa);
+ defer syntax.stop();
+ var cache: Cache = .{};
+ defer cache.deinit(gpa);
+ const source = "const Thing = struct {\n value: u32,\n};\n";
+ const budget = "large.zig".len + source.len;
+ var result = try cache.getBounded(gpa, "large.zig", source, true, false, budget);
+ defer result.deinit(gpa);
+ try std.testing.expect(result.owned);
+ try std.testing.expect(result.analysis.declarations.len > 0);
+ try std.testing.expectEqual(@as(usize, 1), cache.analyses);
+ try std.testing.expectEqual(@as(usize, 0), cache.len);
+ try std.testing.expectEqual(@as(usize, 0), cache.bytes);
+}
diff --git a/src/locations_cache.zig b/src/locations_cache.zig
deleted file mode 100644
index a079226b..00000000
--- a/src/locations_cache.zig
+++ /dev/null
@@ -1,213 +0,0 @@
-const std = @import("std");
-const syntax = @import("syntax.zig");
-
-/// A cache hit borrows its analysis; an entry too large to retain transfers
-/// ownership instead. Call deinit after consuming either result.
-pub const Result = struct {
- analysis: syntax.SourceAnalysis,
- owned: bool = false,
-
- pub fn deinit(result: *Result, gpa: std.mem.Allocator) void {
- if (result.owned) result.analysis.deinit(gpa);
- result.* = undefined;
- }
-};
-
-/// Owned source snapshots keep analysis valid across filesystem changes and
-/// borrowed editor buffers. Entries are ordered from least to most recent.
-pub const Cache = struct {
- pub const max_bytes = 64 * 1024 * 1024;
- pub const max_entries = 64;
-
- const Entry = struct {
- path: []u8,
- source: []u8,
- analysis: syntax.SourceAnalysis,
- declarations_ready: bool,
- colors_ready: bool,
-
- fn size(entry: Entry) usize {
- return entry.path.len + entry.source.len + entry.analysis.colors.len +
- entry.analysis.declarations.len * @sizeOf(syntax.ContextDeclaration);
- }
-
- fn deinit(entry: *Entry, gpa: std.mem.Allocator) void {
- gpa.free(entry.path);
- gpa.free(entry.source);
- entry.analysis.deinit(gpa);
- }
- };
-
- entries: [max_entries]Entry = undefined,
- len: usize = 0,
- bytes: usize = 0,
- analyses: usize = 0,
- hits: usize = 0,
-
- pub fn deinit(cache: *Cache, gpa: std.mem.Allocator) void {
- for (cache.entries[0..cache.len]) |*entry| entry.deinit(gpa);
- cache.* = .{};
- }
-
- /// Borrowed slices remain valid until the next get or cache deinit; an
- /// owned result remains valid until Result.deinit. All calls for this
- /// cache must use the same allocator. Unsupported languages
- /// are cached too, including their empty analysis arrays.
- pub fn get(cache: *Cache, gpa: std.mem.Allocator, path: []const u8, source: []const u8, want_declarations: bool, want_colors: bool) !Result {
- return cache.getBounded(gpa, path, source, want_declarations, want_colors, max_bytes);
- }
-
- fn getBounded(cache: *Cache, gpa: std.mem.Allocator, path: []const u8, source: []const u8, want_declarations: bool, want_colors: bool, budget: usize) !Result {
- if (source.len > budget or path.len > budget - source.len) return error.SourceTooLarge;
- var previous: ?usize = null;
- var declarations = want_declarations;
- var colors = want_colors;
- for (cache.entries[0..cache.len], 0..) |entry, index| {
- if (!std.mem.eql(u8, entry.path, path)) continue;
- previous = index;
- if (!std.mem.eql(u8, entry.source, source)) break;
- if ((!want_declarations or entry.declarations_ready) and (!want_colors or entry.colors_ready)) {
- // Moving the small ownership record keeps the underlying
- // allocations intact and avoids timestamp/overflow state.
- std.mem.copyForwards(Entry, cache.entries[index .. cache.len - 1], cache.entries[index + 1 .. cache.len]);
- cache.entries[cache.len - 1] = entry;
- cache.hits +|= 1;
- return .{ .analysis = entry.analysis };
- }
- declarations = declarations or entry.declarations_ready;
- colors = colors or entry.colors_ready;
- break;
- }
- // A supported full-source color map needs one byte per source byte.
- // Reject that known minimum before parsing, so the caller's uncached
- // path does not repeat an expensive analysis for oversized inputs.
- const input_bytes = path.len + source.len;
- if (colors and syntax.supportsPath(path) and source.len > budget - input_bytes) return error.SourceTooLarge;
- var analysis = try syntax.analyzeSource(gpa, path, source, declarations, colors);
- errdefer analysis.deinit(gpa);
- cache.analyses +|= 1;
- const analysis_bytes = analysis.colors.len + analysis.declarations.len * @sizeOf(syntax.ContextDeclaration);
- if (analysis_bytes > budget - input_bytes) return .{ .analysis = analysis, .owned = true };
- const owned_path = try gpa.dupe(u8, path);
- errdefer gpa.free(owned_path);
- const owned_source = try gpa.dupe(u8, source);
- errdefer gpa.free(owned_source);
- const next: Entry = .{
- .path = owned_path,
- .source = owned_source,
- .analysis = analysis,
- .declarations_ready = declarations,
- .colors_ready = colors,
- };
- // Commit only after every allocation succeeds. A failed refresh leaves
- // the previous cached snapshot usable by the next request.
- if (previous) |index| cache.remove(gpa, index);
- while (cache.len == max_entries or cache.bytes > budget - next.size()) cache.remove(gpa, 0);
- cache.entries[cache.len] = next;
- cache.len += 1;
- cache.bytes += next.size();
- return .{ .analysis = next.analysis };
- }
-
- fn remove(cache: *Cache, gpa: std.mem.Allocator, index: usize) void {
- cache.bytes -= cache.entries[index].size();
- cache.entries[index].deinit(gpa);
- std.mem.copyForwards(Entry, cache.entries[index .. cache.len - 1], cache.entries[index + 1 .. cache.len]);
- cache.len -= 1;
- }
-};
-
-test "locations cache validates exact source and path and upgrades analysis" {
- const gpa = std.testing.allocator;
- syntax.start(gpa);
- defer syntax.stop();
- var cache: Cache = .{};
- defer cache.deinit(gpa);
- const source = "const Thing = struct {\n value: u32,\n};\n";
- _ = try cache.get(gpa, "a.zig", source, true, false);
- try std.testing.expectEqual(@as(usize, 1), cache.analyses);
- _ = try cache.get(gpa, "a.zig", source, true, false);
- try std.testing.expectEqual(@as(usize, 1), cache.analyses);
- try std.testing.expectEqual(@as(usize, 1), cache.hits);
- _ = try cache.get(gpa, "a.zig", source, false, true);
- try std.testing.expectEqual(@as(usize, 2), cache.analyses);
- try std.testing.expect(cache.entries[0].declarations_ready and cache.entries[0].colors_ready);
- _ = try cache.get(gpa, "a.zig", source, true, true);
- try std.testing.expectEqual(@as(usize, 2), cache.analyses);
- _ = try cache.get(gpa, "a.zig", "const Thing = struct {\n other: u32,\n};\n", true, true);
- try std.testing.expectEqual(@as(usize, 3), cache.analyses);
- try std.testing.expectEqual(@as(usize, 1), cache.len);
- _ = try cache.get(gpa, "a.txt", source, true, true);
- try std.testing.expectEqual(@as(usize, 4), cache.analyses);
- try std.testing.expectEqual(@as(usize, 2), cache.len);
-}
-
-test "locations cache bounds memory and entries and evicts least recently used" {
- const gpa = std.testing.allocator;
- var cache: Cache = .{};
- defer cache.deinit(gpa);
- for (0..Cache.max_entries) |index| {
- var path: [32]u8 = undefined;
- _ = try cache.get(gpa, try std.fmt.bufPrint(&path, "{d}.unknown", .{index}), "source", false, false);
- }
- _ = try cache.get(gpa, "0.unknown", "source", false, false);
- _ = try cache.get(gpa, "new.unknown", "source", false, false);
- try std.testing.expectEqual(Cache.max_entries, cache.len);
- try std.testing.expectEqualStrings("2.unknown", cache.entries[0].path);
- const count = cache.analyses;
- _ = try cache.get(gpa, "0.unknown", "source", false, false);
- try std.testing.expectEqual(count, cache.analyses);
- cache.deinit(gpa);
- _ = try cache.getBounded(gpa, "a.unknown", "source", false, false, 40);
- _ = try cache.getBounded(gpa, "b.unknown", "source", false, false, 40);
- _ = try cache.getBounded(gpa, "c.unknown", "source", false, false, 40);
- try std.testing.expectEqual(@as(usize, 2), cache.len);
- try std.testing.expect(cache.bytes <= 40);
- try std.testing.expectEqualStrings("b.unknown", cache.entries[0].path);
- try std.testing.expectError(error.SourceTooLarge, cache.getBounded(gpa, "large.unknown", "x" ** 41, false, false, 40));
- try std.testing.expectEqual(@as(usize, 2), cache.len);
- if (syntax.supportsPath("large.zig")) {
- const before = cache.analyses;
- try std.testing.expectError(error.SourceTooLarge, cache.getBounded(gpa, "large.zig", "x" ** 25, true, true, 40));
- try std.testing.expectEqual(before, cache.analyses);
- }
- cache.deinit(gpa);
- try std.testing.expectEqual(@as(usize, 0), cache.bytes);
- try std.testing.expectEqual(@as(usize, 0), cache.len);
-}
-
-test "locations cache keeps its previous snapshot after allocation failure" {
- var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
- const gpa = failing.allocator();
- var cache: Cache = .{};
- defer cache.deinit(gpa);
- _ = try cache.get(gpa, "a.unknown", "old bytes", false, false);
- const retained = cache.bytes;
- failing.fail_index = failing.alloc_index;
- try std.testing.expectError(error.OutOfMemory, cache.get(gpa, "a.unknown", "new bytes", false, false));
- try std.testing.expectEqual(@as(usize, 1), cache.len);
- try std.testing.expectEqual(retained, cache.bytes);
- try std.testing.expectEqualStrings("old bytes", cache.entries[0].source);
- // This succeeds with allocations still disabled because the old entry
- // remains valid and the hit path moves ownership records only.
- _ = try cache.get(gpa, "a.unknown", "old bytes", false, false);
- try std.testing.expectEqual(@as(usize, 1), cache.hits);
-}
-
-test "locations cache transfers oversized completed analysis without reparsing" {
- if (!syntax.supportsPath("large.zig")) return error.SkipZigTest;
- const gpa = std.testing.allocator;
- syntax.start(gpa);
- defer syntax.stop();
- var cache: Cache = .{};
- defer cache.deinit(gpa);
- const source = "const Thing = struct {\n value: u32,\n};\n";
- const budget = "large.zig".len + source.len;
- var result = try cache.getBounded(gpa, "large.zig", source, true, false, budget);
- defer result.deinit(gpa);
- try std.testing.expect(result.owned);
- try std.testing.expect(result.analysis.declarations.len > 0);
- try std.testing.expectEqual(@as(usize, 1), cache.analyses);
- try std.testing.expectEqual(@as(usize, 0), cache.len);
- try std.testing.expectEqual(@as(usize, 0), cache.bytes);
-}
diff --git a/src/locations_config.zig b/src/locations_config.zig
deleted file mode 100644
index 6b23575a..00000000
--- a/src/locations_config.zig
+++ /dev/null
@@ -1,75 +0,0 @@
-const std = @import("std");
-
-pub const Config = struct {
- pub const Layout = enum { @"inline", stacked };
- context: u16 = 0,
- tscontext: bool = false,
- tslocations: bool = true,
- layout: Layout = .stacked,
-
- /// Apply only supplied fields. A malformed token rejects the whole update.
- /// Repeating a field uses its last supplied value.
- pub fn parse(current: Config, text: []const u8) !Config {
- var result = current;
- var tokens = std.mem.tokenizeAny(u8, text, " \t\r\n");
- while (tokens.next()) |token| {
- const colon = std.mem.indexOfScalar(u8, token, ':') orelse return error.ExpectedKeyValue;
- const key = token[0..colon];
- const value = token[colon + 1 ..];
- var found = false;
- inline for (@typeInfo(Config).@"struct".fields) |field| {
- if (std.mem.eql(u8, key, field.name)) {
- @field(result, field.name) = switch (@typeInfo(field.type)) {
- .bool => if (std.mem.eql(u8, value, "on")) true else if (std.mem.eql(u8, value, "off")) false else return error.ExpectedOnOrOff,
- .int => std.fmt.parseInt(field.type, value, 10) catch return error.InvalidContext,
- .@"enum" => std.meta.stringToEnum(field.type, value) orelse return error.InvalidLayout,
- else => @compileError("unsupported location setting type"),
- };
- found = true;
- }
- }
- if (!found) return error.UnknownLocationSetting;
- }
- return result;
- }
-
- pub fn write(config: Config, writer: *std.Io.Writer) !void {
- inline for (@typeInfo(Config).@"struct".fields, 0..) |field, index| {
- if (index > 0) try writer.writeByte(' ');
- try writer.writeAll(field.name ++ ":");
- const value = @field(config, field.name);
- switch (@typeInfo(field.type)) {
- .bool => try writer.writeAll(if (value) "on" else "off"),
- .int => try writer.print("{d}", .{value}),
- .@"enum" => try writer.writeAll(@tagName(value)),
- else => @compileError("unsupported location setting type"),
- }
- }
- }
-};
-
-test "LocationsConfig reflection round trip and partial updates" {
- var config: Config = .{};
- config = try config.parse("context:5 tscontext:on");
- config = try config.parse("tslocations:off");
- try std.testing.expectEqual(Config{ .context = 5, .tscontext = true, .tslocations = false }, config);
- var out: std.Io.Writer.Allocating = .init(std.testing.allocator);
- defer out.deinit();
- try config.write(&out.writer);
- try std.testing.expectEqualStrings("context:5 tscontext:on tslocations:off layout:stacked", out.written());
- try std.testing.expectEqual(config, try (Config{}).parse(out.written()));
- try std.testing.expectEqual(config, try config.parse(" \n\t"));
- try std.testing.expectEqual(@as(u16, 2), (try config.parse("context:1 context:2")).context);
- try std.testing.expectEqual(Config.Layout.@"inline", (try config.parse("layout:inline")).layout);
-}
-
-test "LocationsConfig rejects invalid updates atomically" {
- const config: Config = .{ .context = 3, .tscontext = true };
- try std.testing.expectError(error.UnknownLocationSetting, config.parse("context:5 unknown:on"));
- try std.testing.expectError(error.ExpectedOnOrOff, config.parse("context:5 tscontext:true"));
- try std.testing.expectError(error.ExpectedKeyValue, config.parse("context"));
- try std.testing.expectError(error.InvalidLayout, config.parse("context:5 layout:sideways"));
- for ([_][]const u8{ "context:", "context:-1", "context:65536", "context:five" }) |input|
- try std.testing.expectError(error.InvalidContext, config.parse(input));
- try std.testing.expectEqual(@as(u16, 3), config.context);
-}
diff --git a/src/look.zig b/src/look.zig
index fe5a3ac1..65d001a9 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -21,7 +21,7 @@ const Color = @import("surface.zig").Color;
const pdf = panes.Pdf.pdf;
const Pane = panes.Pane;
const MAX_PANES = pardes.MAX_PANES;
-const tag_layer = @import("tag_layer.zig");
+const Layer = @import("Layer.zig");
const TagHit = pardes.TagHit;
const BOX_H = pardes.BOX_H;
const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
@@ -946,14 +946,14 @@ fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_h
if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown|
if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
if (p.look_hover_preview) |shown|
- if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and tag_layer.sameCell(shown.tag_hit, tag_hit)) return;
+ if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and Layer.sameCell(shown.tag_hit, tag_hit)) return;
if (p.look_hover_wait) |waiting|
- if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and tag_layer.sameCell(waiting.tag_hit, tag_hit)) return;
+ if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and Layer.sameCell(waiting.tag_hit, tag_hit)) return;
// Found nothing to open here a moment ago: the pointer resting on it
// does not start another wait (and another frame) every frame.
if (p.look_hover_refused) |refused|
- if (refused.col == col and refused.row == row and refused.pane == id and refused.serial == pane.serial and mouse.sameBodyCell(refused.body_hit, body_hit) and tag_layer.sameCell(refused.tag_hit, tag_hit)) return;
+ if (refused.col == col and refused.row == row and refused.pane == id and refused.serial == pane.serial and mouse.sameBodyCell(refused.body_hit, body_hit) and Layer.sameCell(refused.tag_hit, tag_hit)) return;
p.look_hover_refused = null;
cancelLookHover(p);
diff --git a/src/main.zig b/src/main.zig
index ec2472d7..6ea05479 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -408,10 +408,30 @@ fn nativeMain(init: std.process.Init) !void {
if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding;
const target = @import("look.zig").parsePathLine(word);
var realbuf: [4096]u8 = undefined;
- const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path;
- var command_buf: [8192]u8 = undefined;
- const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding;
- ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding;
+ var newbuf: [4096]u8 = undefined;
+ const found_path: ?[]const u8 = if (pardes.filesystem.isVirtual(target.path)) target.path else if (pardes.filesystem.resolveOs(target.path, &realbuf)) |r| r.path else null;
+ const path = found_path orelse named: {
+ // A name not there yet, as acme's B takes one: its directory
+ // resolved and the name kept, a pane made for it that Save
+ // creates the file from.
+ const base = std.fs.path.basename(target.path);
+ if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) break :forwarding;
+ const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse break :forwarding;
+ break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding;
+ };
+ if (found_path != null) {
+ var command_buf: [8192]u8 = undefined;
+ const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding;
+ ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding;
+ } else {
+ const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch break :forwarding;
+ const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch break :forwarding;
+ var name_buf: [64]u8 = undefined;
+ const name = try std.fmt.bufPrint(&name_buf, "/pane/{d}/name", .{serial});
+ var line_buf: [4200]u8 = undefined;
+ const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch break :forwarding;
+ ninep_io.Client.write(arena, parent.dial, name, line) catch break :forwarding;
+ }
if (wait) waitForDel(init.io, arena, parent.dial, path);
return;
}
@@ -468,7 +488,15 @@ fn nativeMain(init: std.process.Init) !void {
std.process.exit(1);
}
switch (pardes.platform) {
- .tty => try @import("tty/tty.zig").run(init, opts, attach),
+ .tty => @import("tty/tty.zig").run(init, opts, attach) catch |err| switch (err) {
+ // No controlling terminal (a detached pty, a daemon): said, not
+ // an error trace.
+ error.NoDevice => {
+ std.Io.File.stderr().writeStreamingAll(init.io, "pardes: no terminal to draw on: /dev/tty will not open; run it in a terminal, or --detach\n") catch {};
+ std.process.exit(1);
+ },
+ else => return err,
+ },
.gui => try @import("gui/gui.zig").run(init, opts, attach),
.web, .macos, .esp32p4 => unreachable,
}
@@ -493,10 +521,10 @@ test {
_ = pardes.config.User;
_ = @import("crash.zig");
_ = @import("memory.zig");
- _ = @import("locations_cache.zig");
+ _ = @import("locations.zig");
_ = @import("fs.zig");
_ = @import("detached/wire.zig");
- _ = @import("shader_build.zig");
+ _ = @import("ShaderBuild.zig");
_ = @import("detached/server.zig");
_ = @import("detached/client.zig");
_ = @import("9p_io.zig");
diff --git a/src/Mini.zig b/src/mini.zig
index a6aef15a..a6aef15a 100644
--- a/src/Mini.zig
+++ b/src/mini.zig
diff --git a/src/mouse.zig b/src/mouse.zig
index 5bfd92ee..cb0378de 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -22,7 +22,7 @@ const pdf = panes.Pdf.pdf;
const platform = pardes.platform;
const Pane = panes.Pane;
const MAX_PANES = pardes.MAX_PANES;
-const tag_layer = @import("tag_layer.zig");
+const Layer = @import("Layer.zig");
const TOPBAR_H = pardes.TOPBAR_H;
const BOX_H = pardes.BOX_H;
const TAG_GAP = pardes.TAG_GAP;
@@ -332,7 +332,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const row: u16 = @intCast(@min(mrow -| base, (if (p.header_column != null) p.columnBarHeight() else p.topBarHeight()) -| 1));
const line: u16 = @intCast(@min(row + p.header_top, modal.lineCount(text) -| 1));
const bar = modal.lineSlice(text, line);
- const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + p.header_scroll));
+ const at = @min(bar.len, panes.File.rawAtDisplay(bar, (Layer.columnAt(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + p.header_scroll));
// the anchor stays where the press put it
t.cur_row = line;
t.cur_col = @intCast(at);
@@ -368,7 +368,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
if (line > 0 and line >= modal.lineCount(text)) return;
const bar = modal.lineSlice(text, line);
const scroll = if (focused) p.header_scroll else 0;
- const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + scroll));
+ const at = @min(bar.len, panes.File.rawAtDisplay(bar, (Layer.columnAt(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + scroll));
if (m.button == config.select_button) {
// A clicked header is typed straight into, as a clicked tag is.
if (!focused) tagline.enterHeader(p, column);
@@ -668,7 +668,7 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo
// nearest the pointer.
const tag_y = p.tagTop(pane, r);
const tag_line: u16 = @intCast(std.math.clamp(@as(i32, mrow) - @as(i32, tag_y), 0, tag_rows - 1));
- if (pane.sel[b].r0 < tag_rows) if (tag_layer.columnAt(p, .pane, s.id, p.pointer_tag_hit, true, tag_line)) |value| {
+ if (pane.sel[b].r0 < tag_rows) if (Layer.columnAt(p, .pane, s.id, p.pointer_tag_hit, true, tag_line)) |value| {
pane.sel[b].c1 = @as(i32, value) + pane.tag_scroll;
pane.sel[b].r1 = tag_line;
return;
@@ -821,7 +821,7 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void {
const forward = gesture.r1 > gesture.r0 or (gesture.r1 == gesture.r0 and gesture.c1 >= gesture.c0);
const endpoint = rows[if (forward) rows.len - 1 else 0];
const body = body_layer.bodyText(p, p.scratch.allocator(), pane) catch return;
- const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.Terminal.gridOffset(pane))));
+ const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.terminal.gridOffset(pane))));
const byte = if (forward) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo;
pane.body.cur_row = pane.surfRow(endpoint.row);
var shown = std.mem.trimEnd(u8, line[@min(endpoint.prompt_bytes, line.len)..], " \t");
@@ -1137,11 +1137,11 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
if (cut) return;
const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return;
if (y.len == 0) return;
- if (panes.Terminal.reportsMouse(pane)) {
+ if (panes.terminal.reportsMouse(pane)) {
const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows) + 1, 1, 9999));
var mb: [32]u8 = undefined;
- if (panes.Terminal.mouseFormatSgr(pane)) {
+ if (panes.terminal.mouseFormatSgr(pane)) {
p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return);
} else {
// ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 5bf354eb..060e0d6c 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -344,7 +344,7 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
pane.serial,
pane_files.tagOf(p, pane).len,
// A terminal's body is its history, read whole on an open's first read.
- if (pane.isTerminal()) panes.Terminal.screenTextLen(pane) else pane_files.bodyOf(pane).len,
+ if (pane.isTerminal()) panes.terminal.screenTextLen(pane) else pane_files.bodyOf(pane).len,
@as(u32, 0),
@intFromBool(pane_files.dirtyOf(pane)),
pane.cols,
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index c0bf4a9a..fe7af77e 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -1289,7 +1289,7 @@ test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are
try testing.expectEqualStrings("msg - caf\xc3\xa9\n", sanitize("msg - caf\xc3\xa9\n", &out));
}
-test "a long err record is cut between words, with an ellipsis" {
+test "a long err record keeps its end, its middle given up to an ellipsis" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
_ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n");
@@ -1298,7 +1298,9 @@ test "a long err record is cut between words, with an ellipsis" {
const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
const at = std.mem.lastIndexOf(u8, text, "err - ctl: ").?;
const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1];
- try testing.expect(std.mem.endsWith(u8, record, "abcdefgh…\n"));
+ try testing.expect(std.mem.indexOf(u8, record, "…") != null);
+ try testing.expect(std.mem.startsWith(u8, record, "err - ctl: "));
+ try testing.expect(std.mem.endsWith(u8, record, "abcdefgh\"\n") or std.mem.endsWith(u8, record, "abcdefgh \"\n"));
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index bb4ebf86..3fad1fae 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -258,7 +258,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
const pf = &pane.fs;
return switch (f) {
// A terminal's body reads its screen and history, as text.
- .body => if (pane.file == null and pane.isTerminal()) panes.Terminal.screenTextLen(pane) else if (pdfText(p, pane)) |t| t.len else bodyOf(pane).len,
+ .body => if (pane.file == null and pane.isTerminal()) panes.terminal.screenTextLen(pane) else if (pdfText(p, pane)) |t| t.len else bodyOf(pane).len,
.data, .xdata => bodyOf(pane).len,
.tag => tagOf(p, pane).len,
.name => nameOf(pane).len + 1,
@@ -399,7 +399,7 @@ fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
return tree.stagedReply(p, req);
}
if (req.handle != 0 and pane.isTerminal()) return screen.readSnapshot(p, req, pane);
- const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch
+ const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch
return Reply.fail(req.tag, E.NOMEM);
defer p.gpa.free(text);
const out = p.fs.stage(p.gpa);
@@ -571,7 +571,7 @@ fn writeTag(req: Req, pane: *Pane) Reply {
const had = tagline.curTail(pane).len;
const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline);
// Whole or not at all: a write that would pass the limit changes nothing.
- if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: over {d} bytes", .{limits.max_tag_tail}));
+ if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail}));
const take = wholeUtf8(req.data);
// A truncating write drops ONE trailing newline, its whole text's: a
// newline held from a write before goes in once more text follows it.
@@ -1214,7 +1214,9 @@ test "a tag write past the limit is refused whole, naming the limit" {
const big = "w" ** (limits.max_tag_tail + 10);
const r = wr(p, tag, big);
try testing.expectEqual(E.NOSPC, r.errno());
- try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: over "));
+ // `no space` is what 9ns maps to ENOSPC, so a shell through a mount
+ // sees the errno the tree gives.
+ try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: no space: over "));
try testing.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes);
}
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 8ba9f4df..0f5202cc 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -139,9 +139,9 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
// or a line is typed at the prompt. Before the shell's first prompt a
// run waits for it rather than answer busy, so that is not busy.
// A build with no terminal panes (the board) has no marks to read.
- const marks = if (comptime !pardes.panes.Terminal.enabled) null else if (pane.terminal) |state| &state.stream.handler else null;
+ const marks = if (comptime !pardes.panes.terminal.enabled) null else if (pane.terminal) |state| &state.stream.handler else null;
const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or
- (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false)));
+ (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.terminal.promptInputEmpty(pane)) else false)));
const out = p.fs.stage(p.gpa);
const size = winsize(pane);
out.print(p.gpa, "{d:>11} {d:>11} {d:>11}\n", .{ size[0], size[1], @intFromBool(busy) }) catch {};
@@ -222,7 +222,7 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
if (std.mem.trim(u8, line, " \t").len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line);
if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line);
for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line);
- if (comptime !pardes.panes.Terminal.enabled) return tree.failText(req.tag, E.INVAL, e_bad_line);
+ if (comptime !pardes.panes.terminal.enabled) return tree.failText(req.tag, E.INVAL, e_bad_line);
const pf = &pane.fs;
const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line);
const marks = &state.stream.handler;
@@ -235,7 +235,7 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
} else if (pf.unmarked) {
answer(p, slot, "error no prompt marks", .{});
} else if (waitingRun(p, pane) != null or (marks.prompts > 0 and (marks.phase != .input or
- !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id))))
+ !pardes.panes.terminal.promptInputEmpty(pane) or p.hostTtyTaken(id))))
{
// Something is running or someone has typed at the prompt: sending
// now would type into the middle of it. The phase is pardes's own
@@ -288,7 +288,7 @@ pub fn readRun(p: *Pardes, req: Req) Reply {
/// for the prompt means whoever reads the answer can send the next command
/// at once instead of finding the shell still drawing it.
pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
- if (comptime !pardes.panes.Terminal.enabled) return;
+ if (comptime !pardes.panes.terminal.enabled) return;
const state = pane.terminal orelse return;
const pf = &pane.fs;
const slot = waitingRun(p, pane) orelse return;
@@ -299,13 +299,13 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
pf.run = null;
return;
}
- if (marks.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) return;
+ if (marks.phase != .input or !pardes.panes.terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) return;
slot.want = marks.started +% 1;
slot.prompts = marks.prompts;
slot.declined = marks.declined;
p.emitWrite(id, slot.line);
p.emitWrite(id, "\r");
- pardes.panes.Terminal.noteCommand(pane, slot.line);
+ pardes.panes.terminal.noteCommand(pane, slot.line);
slot.phase = .sent;
return;
}
@@ -319,7 +319,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
// typed into an empty prompt, so throw it away with Ctrl-C, which
// drops the whole pending command, and answer at the prompt after
// that, so the next line finds the shell clear.
- const empty = pardes.panes.Terminal.promptInputEmpty(pane);
+ const empty = pardes.panes.terminal.promptInputEmpty(pane);
switch (slot.cleared) {
.no => {
if (marks.continuation) {
@@ -360,7 +360,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
/// Answers a run whose command ended with `status`: the header and the
/// output it printed.
fn finish(p: *Pardes, slot: *Run, pane: *Pane, status_code: ?i32) void {
- const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa, output_rows) catch
+ const printed = pardes.panes.terminal.commandOutput(pane, p.gpa, output_rows) catch
return answer(p, slot, "error out of memory", .{});
defer p.gpa.free(printed.text);
// Keep the tail, cut at a line start so the first line kept is whole.
@@ -400,7 +400,7 @@ pub fn shellExited(p: *Pardes, pane: *Pane, status: ?u8) void {
const code = status orelse return;
if (waitingRun(p, pane)) |slot| if (slot.phase == .sent) {
pane.fs.run = null;
- pardes.panes.Terminal.endOutputHere(pane);
+ pardes.panes.terminal.endOutputHere(pane);
finish(p, slot, pane, code);
};
var buf: [4]u8 = undefined;
@@ -963,10 +963,10 @@ test "a run's answer says cut when its output's start is gone, and reads a bound
// A resize between its start and end reflows the pins with the text.
h = sh.send(p, node);
sh.put(p, c ++ "one\r\ntwo\r\n");
- pardes.panes.Terminal.resizeGrid(pane, gpa, 50, 20);
+ pardes.panes.terminal.resizeGrid(pane, gpa, 50, 20);
sh.put(p, d ++ prompt);
try testing.expectEqualStrings("exit 0\none\ntwo\n", sh.answered(p, node, h, into));
- pardes.panes.Terminal.resizeGrid(pane, gpa, pane.cols, pane.rows);
+ pardes.panes.terminal.resizeGrid(pane, gpa, pane.cols, pane.rows);
// A full-screen program leaves nothing on the primary screen, and a D
// with no status is not a success.
diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig
index f496da03..b991c3e4 100644
--- a/src/ninep/screen.zig
+++ b/src/ninep/screen.zig
@@ -47,7 +47,7 @@ pub fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply {
const snapshot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.snapshot;
if (snapshot.* == null) {
const terminal = pane orelse return Reply.fail(req.tag, E.INVAL);
- snapshot.* = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM);
+ snapshot.* = panes.terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM);
}
const bytes = snapshot.*.?;
const off = @min(req.off, bytes.len);
@@ -129,7 +129,7 @@ test "terminal body handles keep one history snapshot across fragmented reads" {
const node = Node.of(serialOf(p), .body);
p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } });
while (p.nextEffect()) |_| {}
- const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa);
+ const original = try panes.terminal.screenTextAlloc(p.panes[0].?, gpa);
defer gpa.free(original);
const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
try testing.expectEqual(Status.ok, opened.reply.status);
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 30bf073d..5f3ffc9c 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -456,9 +456,59 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply {
pub fn handle(p: *Pardes, req: Req) Reply {
var reply = serve(p, req);
if (reply.status == .err and reply.ename.len == 0) reply.ename = errWords(reply.errno);
+ if (reply.status == .err and reply.ename.len > cloud9.fs.errmax)
+ if (p.scratch.allocator().alloc(u8, cloud9.fs.errmax)) |room| {
+ reply.ename = fitErr(reply.ename, room);
+ } else |_| {};
return reply;
}
+/// A reason longer than an Rerror carries (Plan 9's ERRMAX, 128 bytes)
+/// keeps its end, which says why: the longest path in it -- else the whole
+/// text -- gives up its middle to `…`.
+pub fn fitErr(text: []const u8, out: []u8) []const u8 {
+ const ell = "…";
+ if (text.len <= out.len) return text;
+ const over = text.len - out.len + ell.len;
+ var from: usize = 0;
+ var to: usize = text.len;
+ var words = std.mem.tokenizeAny(u8, text, " \t");
+ var longest: usize = 0;
+ while (words.next()) |w| if (std.mem.indexOfScalar(u8, w, '/') != null and w.len > longest and w.len > over + 2) {
+ longest = w.len;
+ from = @intFromPtr(w.ptr) - @intFromPtr(text.ptr);
+ to = from + w.len;
+ };
+ var a = from + (to - from - over) / 2;
+ var b = a + over;
+ while (a > from and text[a] & 0xC0 == 0x80) a -= 1;
+ while (b < to and text[b] & 0xC0 == 0x80) b += 1;
+ return std.fmt.bufPrint(out, "{s}" ++ ell ++ "{s}", .{ text[0..a], text[b..] }) catch text[0..out.len];
+}
+
+test "a reason past 128 bytes keeps its end: the path in it gives up its middle" {
+ var out: [128]u8 = undefined;
+ const long = "Save /home/someone/projects/" ++ "deep/" ** 30 ++ "file.txt: no such directory";
+ const fit = fitErr(long, &out);
+ try testing.expect(fit.len <= 128);
+ try testing.expect(std.mem.startsWith(u8, fit, "Save /home/someone/"));
+ try testing.expect(std.mem.endsWith(u8, fit, "file.txt: no such directory"));
+ try testing.expect(std.mem.indexOf(u8, fit, "…") != null);
+ try testing.expect(std.unicode.utf8ValidateSlice(fit));
+ // No path: the whole text's middle goes, the end stays.
+ const words = "why " ** 40 ++ "the reason";
+ try testing.expect(std.mem.endsWith(u8, fitErr(words, &out), "the reason"));
+ try testing.expectEqualStrings("short", fitErr("short", &out));
+ // Through the tree: a look at a long ./ name not there says why at the end.
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var line: [512]u8 = undefined;
+ const r = th.wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "./{s}x.txt\n", .{"no-such-dir/" ** 20}));
+ try testing.expectEqual(Status.err, r.reply.status);
+ try testing.expect(r.reply.ename.len <= 128);
+ try testing.expect(std.mem.endsWith(u8, r.reply.ename, "no such file") or std.mem.endsWith(u8, r.reply.ename, "no such directory"));
+}
+
/// A refusal with no reason of its own said in Plan 9's words, not the C
/// library's (`Operation not permitted`); each maps back to its errno in
/// 9ns (enameToErrno), EPERM's to EACCES as Plan 9's does.
@@ -1578,6 +1628,55 @@ test "editor paths resolve to the same nodes the wire serves" {
try testing.expectEqual(before, p.next_serial);
}
+test "random placements, refused or not, leave no pane under its tag and two rows" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var look_line: [4200]u8 = undefined;
+ const looked = try std.fmt.bufPrint(&look_line, "{s}/f.txt\n", .{dir});
+ var seed: u64 = 0;
+ while (seed < 300) : (seed += 1) {
+ var prng = std.Random.DefaultPrng.init(seed);
+ const r = prng.random();
+ const rows = r.intRangeAtMost(u16, 6, 40);
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = r.intRangeAtMost(u16, 60, 240), .rows = rows });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ _ = try p.setTestFile("x\n");
+ while (p.nextEffect()) |_| {}
+ for (0..r.uintLessThan(usize, 3)) |_| _ = th.wr(p, Node.of(serialOf(p), .exec), "Newcol\n");
+ if (r.boolean()) p.settings.placement = .pardes;
+ for (0..40) |step| {
+ var serials: [pardes.MAX_PANES]u32 = undefined;
+ var n: usize = 0;
+ for (p.panes) |slot| if (slot) |pn| {
+ serials[n] = pn.serial;
+ n += 1;
+ };
+ if (n == 0) break;
+ const at = serials[r.uintLessThan(usize, n)];
+ const action = r.uintLessThan(u8, 8);
+ switch (action) {
+ 0 => _ = th.wr(p, Node.of(at, .exec), "New\n"),
+ 1 => _ = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }),
+ 2 => _ = th.wr(p, Node.of(at, .exec), "Newcol\n"),
+ 3 => _ = th.wr(p, Node.of(at, .exec), "Edit =\n"),
+ 4 => _ = th.wr(p, Node.of(at, .exec), "Tty\n"),
+ 5 => _ = th.wr(p, Node.of(at, .look), looked),
+ 6 => _ = th.wr(p, Node.of(at, .exec), "Delcol\n"),
+ else => _ = th.wr(p, Node.of(at, .ctl), "delete\n"),
+ }
+ for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) {
+ std.debug.print("seed {d} rows {d} step {d} action {d}: column {d} under its minimums\n", .{ seed, rows, step, action, c });
+ for (p.col_panes[c][0..p.col_n[c]]) |id| std.debug.print(" pane {d} h {d} min {d}\n", .{ id, p.rects[id].h, layout.minRows(p, id) });
+ return error.TestUnexpectedResult;
+ };
+ }
+ }
+}
+
test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/panes.zig b/src/panes.zig
index 32ad7b6b..0380f89f 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -89,7 +89,7 @@ pub const Pane = struct {
pub const Cwd = union(enum) { none, inherited: *Pane, owned: []u8 };
pub const Focus = enum { body, tag };
- terminal: ?*Terminal.State = null,
+ terminal: ?*terminal.State = null,
gpa: std.mem.Allocator,
serial: u32 = 0,
/// What the control filesystem keeps of this pane -- addr and limit, the
@@ -116,7 +116,7 @@ pub const Pane = struct {
/// A shell has run in it (Pardes.acknowledgeShell): a Tty whose first
/// shell fails to start closes, one started again keeps its pane.
had_shell: bool = false,
- pending_command: Terminal.PendingCommand = .{},
+ pending_command: terminal.PendingCommand = .{},
/// The last command line pardes typed into this terminal (an exec, a
/// middle click, a pty/run): its first word, which Kill matches, and
/// the shell's command count its start (C mark) reaches.
@@ -227,7 +227,7 @@ pub const Pane = struct {
next_pointer_selection: u64 = 0,
/// terminals only: the typed-text buffer standing in for shell rows
- ovl: ?Terminal.EditBuffer = null,
+ ovl: ?terminal.EditBuffer = null,
tty_filter: bool = false,
msg: [256]u8 = undefined,
msg_len: u16 = 0,
@@ -486,7 +486,7 @@ pub const Pane = struct {
pub fn scroll(pane: *Pane) i32 {
if (pane.file) |f| return @intCast(f.scroll);
if (comptime Pdf.enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll);
- return pane.surfRow(Terminal.gridOffset(pane));
+ return pane.surfRow(terminal.gridOffset(pane));
}
pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } {
@@ -531,8 +531,8 @@ pub const Pane = struct {
}
} else {
// the vt scrolls in SHELL rows; convert through the edit buffer
- const off = Terminal.gridOffset(pane);
- Terminal.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off);
+ const off = terminal.gridOffset(pane);
+ terminal.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off);
}
}
@@ -611,8 +611,8 @@ pub const Pane = struct {
pane.body.cur_row = pane.scroll();
pane.body.cur_col = 0;
} else {
- const cur = Terminal.gridCursor(pane);
- pane.body.cur_row = pane.surfRow(@as(i32, cur.y) + Terminal.gridOffset(pane));
+ const cur = terminal.gridCursor(pane);
+ pane.body.cur_row = pane.surfRow(@as(i32, cur.y) + terminal.gridOffset(pane));
pane.body.cur_col = @intCast(cur.x);
}
pane.body.cur_pinned = true;
@@ -636,21 +636,21 @@ pub const File = @import("File.zig");
pub const Output = @import("Output.zig");
-pub const Mini = @import("Mini.zig");
+pub const mini = @import("mini.zig");
pub const Image = @import("image.zig");
pub const Pdf = @import("pdf_view.zig");
-pub const Terminal = @import("Terminal.zig");
+pub const terminal = @import("terminal.zig");
test {
_ = Pane;
_ = Text;
_ = File;
_ = Output;
- _ = Mini;
+ _ = mini;
_ = Image;
_ = Pdf;
- _ = Terminal;
+ _ = terminal;
}
diff --git a/src/pardes.zig b/src/pardes.zig
index 19fe51d1..623af3c9 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -9,9 +9,9 @@ pub const look = @import("look.zig");
pub const filesystem = @import("fs.zig");
pub const ctlfs = @import("ninep/tree.zig");
pub const syntax = @import("syntax.zig");
-pub const locations_config = @import("locations_config.zig");
const tracy = @import("tracy.zig");
pub const panes = @import("panes.zig");
+const locations = @import("locations.zig");
pub const tagline = @import("tagline.zig");
pub const exec = @import("exec.zig");
const recent_files = @import("recent.zig");
@@ -492,7 +492,6 @@ test {
_ = @import("surface.zig");
_ = @import("body_layer.zig");
_ = @import("draw.zig");
- _ = @import("tag_layer.zig");
_ = @import("dump.zig"); // its tests ran nowhere before
_ = @import("recent.zig");
_ = @import("Presentation.zig");
@@ -767,7 +766,7 @@ test "LocationsConfig command reports partial updates and survives restore" {
_ = try p.setTestFile("source\n");
try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:5 tscontext:on"));
try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig tslocations:off"));
- const expected: locations_config.Config = .{ .context = 5, .tscontext = true, .tslocations = false };
+ const expected: locations.Config = .{ .context = 5, .tscontext = true, .tslocations = false };
try std.testing.expectEqual(expected, p.locations_config);
try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:2 unknown:on"));
try std.testing.expectEqual(expected, p.locations_config);
@@ -1196,9 +1195,9 @@ test "Tty9p marks only the new Linux terminal for a mounted shell" {
try std.testing.expect(pane.v9fs_on_spawn);
try std.testing.expect(!pane.greet);
try std.testing.expect(!exec.takesCommandLine(p, p.active));
- panes.Terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07");
+ panes.terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07");
try std.testing.expect(exec.takesCommandLine(p, p.active));
- panes.Terminal.feedOutput(p, pane, "\x1b]133;C\x07");
+ panes.terminal.feedOutput(p, pane, "\x1b]133;C\x07");
try std.testing.expect(!exec.takesCommandLine(p, p.active));
}
@@ -1559,7 +1558,7 @@ test "a command of several lines keeps its tag one row, and its echo does not st
_ = exec.execute(p, 1, two);
while (p.nextEffect()) |_| {}
_ = try p.render(frame.allocator());
- const body = try panes.Terminal.screenTextAlloc(pane, gpa);
+ const body = try panes.terminal.screenTextAlloc(pane, gpa);
defer gpa.free(body);
try std.testing.expect(std.mem.indexOf(u8, body, "% def greet(name):\n return name") != null);
}
@@ -1879,7 +1878,7 @@ test "a command pane shows how its command ended, and the next command there run
const pane = p.panes[dst].?;
// Its first command is echoed as every later one is.
{
- const first = try panes.Terminal.screenTextAlloc(pane, gpa);
+ const first = try panes.terminal.screenTextAlloc(pane, gpa);
defer gpa.free(first);
try std.testing.expect(std.mem.startsWith(u8, first, "% make -j8"));
}
@@ -1910,16 +1909,16 @@ test "a command pane shows how its command ended, and the next command there run
// A program that crashed out of its full screen leaves the emulator
// in its modes; the next command starts clear of them.
p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "\x1b[?1049h\x1b[?2004h\x1b[?1000h" } });
- try std.testing.expect(panes.Terminal.bracketedPaste(pane));
+ try std.testing.expect(panes.terminal.bracketedPaste(pane));
// Done, the directory's next command runs there, below what it showed.
try std.testing.expectEqual(@as(?usize, dst), exec.execute(p, 1, "make test"));
- try std.testing.expect(!panes.Terminal.bracketedPaste(pane));
+ try std.testing.expect(!panes.terminal.bracketedPaste(pane));
var respawned = false;
while (p.nextEffect()) |effect| if (effect == .spawn and effect.spawn.pane == dst) {
respawned = true;
};
try std.testing.expect(respawned);
- const body = try panes.Terminal.screenTextAlloc(pane, gpa);
+ const body = try panes.terminal.screenTextAlloc(pane, gpa);
defer gpa.free(body);
const said = std.mem.indexOf(u8, body, "compiled") orelse return error.OutputLost;
const ended = std.mem.indexOf(u8, body, "exit 2") orelse return error.ExitLost;
@@ -4393,9 +4392,9 @@ pub const Pardes = struct {
/// (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 = .{},
- tty_filter_palette: panes.Terminal.FilterPalette = .{},
+ locations_config: locations.Config = .{},
+ locations_cache: locations.Cache = .{},
+ tty_filter_palette: panes.terminal.FilterPalette = .{},
font_request_taken: bool = false,
custom_theme: ?Theme = null,
/// bodyChrome's: the theme it was worked out for, and it.
@@ -4581,8 +4580,8 @@ pub const Pardes = struct {
/// that still own their loop pass their own arena to `render` instead.
frame_arena: std.heap.ArenaAllocator,
/// the terminal motion surface, memoized against the pane it was built
- /// for — see panes.Terminal.RowsCache for the lifetime rule
- shell_rows: panes.Terminal.RowsCache = .{},
+ /// for — see panes.terminal.RowsCache for the lifetime rule
+ shell_rows: panes.terminal.RowsCache = .{},
/// The message row's entry points, called from everywhere as
/// `p.setMessage(...)`; they live with the rest of it in Messages.zig.
@@ -4945,7 +4944,7 @@ pub const Pardes = struct {
}
p.shell_rows.dropPane(pane);
for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot);
- panes.Terminal.deinitPendingCommand(pane);
+ panes.terminal.deinitPendingCommand(pane);
if (pane.command) |line| p.gpa.free(line);
if (pane.shell) |bin| p.gpa.free(bin);
if (pane.image) |*iv| {
@@ -4958,7 +4957,7 @@ pub const Pardes = struct {
pane.tag.deinit(p.gpa);
pane.input.deinit(p.gpa);
pane.jump.deinit(p.gpa);
- panes.Terminal.deinitEmulator(pane, p.gpa);
+ panes.terminal.deinitEmulator(pane, p.gpa);
pane.clearCwd();
pane.fs.deinit(p.gpa);
p.gpa.destroy(pane);
@@ -4987,12 +4986,12 @@ pub const Pardes = struct {
pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane {
std.debug.assert(p.panes[id] == null);
if (cwd.len > limits.host_path_cap) return error.PathTooLong;
- const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h);
+ const pane = try panes.terminal.create(p.gpa, p.screen_w, p.screen_h);
// Named by the directory it is started in from the first, so the
// shell saying it is there is no rename (`new N /` then `rename`);
// out of memory for it, the shell names it when it says.
if (cwd.len > 0) pane.setOwnedCwd(cwd) catch {};
- panes.Terminal.armShellSpawn(pane);
+ panes.terminal.armShellSpawn(pane);
p.installPane(id, pane);
p.emitSpawn(id, pane.serial, cwd);
return pane;
@@ -5018,7 +5017,7 @@ pub const Pardes = struct {
if (cwd.len > limits.host_path_cap) return error.PathTooLong;
const owned = try p.gpa.dupe(u8, line);
errdefer p.gpa.free(owned);
- const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h);
+ const pane = try panes.terminal.create(p.gpa, p.screen_w, p.screen_h);
pane.command = owned;
pane.command_pty = true;
pane.body.mode = .tty;
@@ -5032,7 +5031,7 @@ pub const Pardes = struct {
pub fn newDocPane(p: *Pardes, id: usize) !*Pane {
std.debug.assert(p.panes[id] == null);
- const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h);
+ const pane = try panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h);
p.installPane(id, pane);
return pane;
}
@@ -5082,6 +5081,15 @@ pub const Pardes = struct {
else
"Newcol: no space for a column: this one is too narrow to split");
};
+ // A narrower column wraps long tags onto more rows, which can leave
+ // a pane under its tag and two rows: its column's rows are shared
+ // out again, and where they cannot hold every minimum there is no
+ // new column, as a size too small is refused.
+ for (0..p.ncol) |k| if (!layout.columnAtMinimums(p, k) and !layout.shareColumn(p, k)) {
+ layout.dropColumn(p, c);
+ layout.compute(p);
+ return p.reportFailure(from_id, "Newcol: no space for a column: the panes' tags would not fit");
+ };
tagline.enterHeader(p, c);
}
@@ -5215,7 +5223,7 @@ pub const Pardes = struct {
} else |_| {}
}
};
- if (id < MAX_PANES) panes.Terminal.shellSpawned(p, id, prompt_marks);
+ if (id < MAX_PANES) panes.terminal.shellSpawned(p, id, prompt_marks);
if (p.settings.shell.effective.set(executable))
p.settings.shell.pending = false;
}
@@ -5350,7 +5358,7 @@ pub const Pardes = struct {
/// The pane a session word runs with in an empty window.
pub fn standIn(p: *Pardes) ?*Pane {
- if (p.stand_in == null) p.stand_in = panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h) catch null;
+ if (p.stand_in == null) p.stand_in = panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h) catch null;
return p.stand_in;
}
@@ -5619,7 +5627,7 @@ pub const Pardes = struct {
return;
}
if (!pane.isTerminal()) return;
- const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch return;
+ const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return;
defer p.gpa.free(text);
p.hostWriteFile(st.pane, st.path.slice(), text);
},
@@ -5831,7 +5839,7 @@ pub const Pardes = struct {
const pane = p.panes[o.pane] orelse return;
pane.shell_spoke = true;
ctlfs.events.notePtyOutput(p, o.pane, o.bytes);
- panes.Terminal.feedOutput(p, pane, o.bytes);
+ panes.terminal.feedOutput(p, pane, o.bytes);
ctlfs.pty.noteMarks(p, o.pane, pane);
exec.flushReplEnter(p, o.pane, true);
},
@@ -5936,7 +5944,7 @@ pub const Pardes = struct {
.normal => edit.enterInsert(p, &pane.body, .at, 1),
.insert => {
edit.exitInsert(p, &pane.body);
- if (pane.isTerminal()) panes.Terminal.enterTty(p, id);
+ if (pane.isTerminal()) panes.terminal.enterTty(p, id);
},
}
}
@@ -5952,7 +5960,7 @@ pub const Pardes = struct {
if (pane.body.mode == .tty) {
pane.body.mode = .normal;
pane.body.normal.clear();
- } else panes.Terminal.enterTty(p, id);
+ } else panes.terminal.enterTty(p, id);
}
/// Type `keys` again (a macro, `.`): through the modal handling as
@@ -6071,7 +6079,7 @@ pub const Pardes = struct {
return exec.runBuiltin(p, .Last, p.active, "", null);
if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and exec.takesCommandLine(p, p.active))
return exec.runBuiltin(p, .Last, p.active, "", null);
- panes.Terminal.followOutput(pane); // typing snaps back to live output
+ panes.terminal.followOutput(pane); // typing snaps back to live output
// Paste stays the window's, the one exception to forwarding a raw
// tty's keys: Ctrl-V types the register at the program and
// Ctrl-Shift-V asks the desktop for its clipboard first. Forwarded,
@@ -6086,7 +6094,7 @@ pub const Pardes = struct {
// Ctrl-V a black hole in every full-screen application.
if (p.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text);
}
- return panes.Terminal.forwardKey(p, p.active, key);
+ return panes.terminal.forwardKey(p, p.active, key);
}
if (p.leader_on) return p.leaderKey(key);
if (p.ctrl_w_pending) {
@@ -6239,7 +6247,7 @@ pub const Pardes = struct {
edit.exitInsert(p, t)
else
edit.handleInsert(p, t, key),
- .tty => panes.Terminal.forwardKey(p, p.active, key),
+ .tty => panes.terminal.forwardKey(p, p.active, key),
}
// `.`'s log: the keys of the normal command that entered insert
// mode, then everything typed until it is left
@@ -6730,7 +6738,7 @@ pub const Pardes = struct {
errdefer p.gpa.free(path);
const history = try panes.File.History.create(p.gpa);
errdefer p.gpa.destroy(history);
- const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h);
+ const pane = try panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h);
errdefer p.gpa.destroy(pane);
try p.deinitPane(p.panes[0].?);
p.panes[0] = null;
@@ -7078,7 +7086,7 @@ pub const Pardes = struct {
// doc panes have no pty/emulator grid to reflow; just record
// the size so bodyText renders the right number of rows
if (pane.isTerminal()) {
- panes.Terminal.resizeGrid(pane, p.gpa, cols, rows);
+ panes.terminal.resizeGrid(pane, p.gpa, cols, rows);
p.shell_rows.markStale(pane); // reflow moved every row
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
pane.fs.winsize = null;
@@ -7087,12 +7095,12 @@ pub const Pardes = struct {
pane.rows = rows;
if (pane.file) |*file| file.syntax_dirty = true;
}
- panes.Terminal.releasePendingCommandIfReady(p, id, pane);
+ panes.terminal.releasePendingCommandIfReady(p, id, pane);
// The greeting `ls` of a new terminal in a directory is typed at
// its shell's FIRST prompt, or never: typed later it would land
// in whatever runs then, or in the middle of a line someone
- // typed (panes.Terminal.greeting).
- if (pane.greet and pane.isTerminal() and p.resize_count > 0) switch (panes.Terminal.greeting(pane)) {
+ // typed (panes.terminal.greeting).
+ if (pane.greet and pane.isTerminal() and p.resize_count > 0) switch (panes.terminal.greeting(pane)) {
.wait => {},
.never => pane.greet = false,
.now => {
@@ -7373,7 +7381,7 @@ test "argv naming nothing boots an errors pane rather than failing the launch" {
}
test "raw terminal cursor obeys visibility without hiding modal and tag cursors" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
defer p.deinit();
@@ -7418,7 +7426,7 @@ test "raw terminal cursor obeys visibility without hiding modal and tag cursors"
}
test "raw terminal cursor follows its active row only while that row is visible" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
const gpa = std.testing.allocator;
for ([_]bool{ false, true }) |tag_bottom| {
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
@@ -7437,13 +7445,13 @@ test "raw terminal cursor follows its active row only while that row is visible"
try std.testing.expectEqual(rect.x + config.GUTTER + 3, live.x);
try std.testing.expectEqual(body_y + 2, live.y);
- panes.Terminal.scrollGrid(pane, -1);
+ panes.terminal.scrollGrid(pane, -1);
const scrolled = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor;
try std.testing.expectEqual(live.x, scrolled.x);
try std.testing.expectEqual(live.y + 1, scrolled.y);
- panes.Terminal.scrollGrid(pane, -@as(i32, pane.rows));
+ panes.terminal.scrollGrid(pane, -@as(i32, pane.rows));
try std.testing.expect((try p.render(frame.allocator())).cursor == null);
- panes.Terminal.followOutput(pane);
+ panes.terminal.followOutput(pane);
const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor;
try std.testing.expectEqual(live.x, returned.x);
try std.testing.expectEqual(live.y, returned.y);
@@ -7887,31 +7895,31 @@ test "tty output follows only from the bottom" {
p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } });
}
p.sync();
- const bottom = panes.Terminal.scrollbar(sp).offset;
+ const bottom = panes.terminal.scrollbar(sp).offset;
try std.testing.expect(bottom > 0);
// Scrolled back, the reader stays put however much the shell prints.
sp.scrollBy(-10);
p.sync();
- const parked = panes.Terminal.scrollbar(sp).offset;
+ const parked = panes.terminal.scrollbar(sp).offset;
try std.testing.expect(parked < bottom);
for (60..70) |i| {
var buf: [32]u8 = undefined;
p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } });
}
p.sync();
- try std.testing.expectEqual(parked, panes.Terminal.scrollbar(sp).offset);
+ try std.testing.expectEqual(parked, panes.terminal.scrollbar(sp).offset);
// Typing snaps back to live output, so nobody types blind.
p.update(.{ .key = .{ .cp = 'x', .text = "x" } });
p.sync();
- try std.testing.expect(panes.Terminal.scrollbar(sp).offset > parked);
+ try std.testing.expect(panes.terminal.scrollbar(sp).offset > parked);
// ...and from there output follows again.
- const live = panes.Terminal.scrollbar(sp).offset;
+ const live = panes.terminal.scrollbar(sp).offset;
p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "tail\r\n" } });
p.sync();
- try std.testing.expect(panes.Terminal.scrollbar(sp).offset > live);
+ try std.testing.expect(panes.terminal.scrollbar(sp).offset > live);
}
test "Esc back into a file leaves its view where it was" {
@@ -8127,7 +8135,7 @@ test "an unasked desktop paste reaches a tty pane's program, not its buffer" {
_ = drainWrites(p, &buf);
const pane = p.panes[0].?;
- panes.Terminal.enterTty(p, 0);
+ panes.terminal.enterTty(p, 0);
// No request behind it: the window manager's own paste, or SDL answering a
// Ctrl-Shift-V the desktop handled. It still has to reach the shell.
p.update(.{ .paste = "ls -la" });
@@ -8145,7 +8153,7 @@ test "a paste larger than the effect ring reaches the program whole and in order
const buf = try gpa.alloc(u8, 1 << 20);
defer gpa.free(buf);
_ = drainWrites(p, buf);
- panes.Terminal.enterTty(p, 0);
+ panes.terminal.enterTty(p, 0);
const text = try gpa.alloc(u8, 300 * 1024);
defer gpa.free(text);
@@ -8158,7 +8166,7 @@ test "a paste larger than the effect ring reaches the program whole and in order
test "a bracketed paste larger than the ring still closes its bracket" {
if (platform == .web) return;
- if (comptime !panes.Terminal.enabled) return;
+ if (comptime !panes.terminal.enabled) return;
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
defer p.deinit();
@@ -8166,10 +8174,10 @@ test "a bracketed paste larger than the ring still closes its bracket" {
const buf = try gpa.alloc(u8, 1 << 20);
defer gpa.free(buf);
_ = drainWrites(p, buf);
- panes.Terminal.enterTty(p, 0);
+ panes.terminal.enterTty(p, 0);
p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } });
- try std.testing.expect(panes.Terminal.bracketedPaste(p.panes[0].?));
+ try std.testing.expect(panes.terminal.bracketedPaste(p.panes[0].?));
const text = try gpa.alloc(u8, 300 * 1024);
defer gpa.free(text);
@memset(text, 'z');
@@ -8189,7 +8197,7 @@ test "pasted bytes still queued at shutdown are freed, not leaked" {
const buf = try gpa.alloc(u8, 1 << 20);
defer gpa.free(buf);
_ = drainWrites(p, buf);
- panes.Terminal.enterTty(p, 0);
+ panes.terminal.enterTty(p, 0);
const text = try gpa.alloc(u8, 300 * 1024);
defer gpa.free(text);
@@ -8219,14 +8227,14 @@ test "leaving tty hides the prompt and keeps the command typed at it" {
const rowOf = struct {
fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 {
- const rows = panes.Terminal.shellRows(pp, pane) catch return null;
+ const rows = panes.terminal.shellRows(pp, pane) catch return null;
for (rows) |r| if (std.mem.indexOf(u8, r, needle) != null) return r;
return null;
}
}.at;
const bodyRowOf = struct {
fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 {
- const body = panes.Terminal.bodyText(pp.scratch.allocator(), pane) catch return null;
+ const body = panes.terminal.bodyText(pp.scratch.allocator(), pane) catch return null;
var it = std.mem.splitScalar(u8, body, '\n');
while (it.next()) |r| if (std.mem.indexOf(u8, r, needle) != null) return r;
return null;
@@ -8282,7 +8290,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt"
p.shell_rows.stale = true;
// The command shows LEFT-HUGGED, so its column 3 is the '3'...
- const rows = try panes.Terminal.shellRows(p, sp);
+ const rows = try panes.terminal.shellRows(p, sp);
var row: i32 = 0;
const at = for (rows, 0..) |r, i| {
if (std.mem.indexOf(u8, r, "0123456789") != null) break i;
@@ -8296,7 +8304,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt"
sp.body.cur_col = 3;
sp.body.cur_pinned = true;
while (p.nextEffect()) |_| {}
- panes.Terminal.enterTty(p, shell);
+ panes.terminal.enterTty(p, shell);
// The walk is arrow keys the shell understands. Seven lefts: readline's
// cursor sits past the '9' and the click was on the '3'.
@@ -8339,7 +8347,7 @@ test "board heap: every inline ring the board pays for still fits its budget" {
const effect_ring = 128 * @sizeOf(Effect);
// ...and the undo history, the largest in `Pane` once the terminal is out:
// 16 snapshots on the board against 256 on a desktop.
- const undo_history = 2 * 16 * @sizeOf(panes.Terminal.Snapshot);
+ const undo_history = 2 * 16 * @sizeOf(panes.terminal.Snapshot);
const grid = @as(usize, board_cols) * board_rows * (2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff));
// Each of the three separately, so a failure names the one that grew
@@ -8467,7 +8475,7 @@ test "jump history terminal rectangle follows output and clears on reflow" {
try std.testing.expectEqualStrings("alpha", try edit.selectionText(p, pane, pane.sel[0]));
for (0..40) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "more output\r\n" } });
try std.testing.expectEqualStrings("alpha", try edit.selectionText(p, pane, pane.sel[0]));
- panes.Terminal.resizeGrid(pane, p.gpa, pane.cols - 1, pane.rows);
+ panes.terminal.resizeGrid(pane, p.gpa, pane.cols - 1, pane.rows);
try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null);
try std.testing.expectEqual(.none, pane.sel[0].state);
}
diff --git a/src/tag_layer.zig b/src/tag_layer.zig
deleted file mode 100644
index 213ebc07..00000000
--- a/src/tag_layer.zig
+++ /dev/null
@@ -1,32 +0,0 @@
-//! Pointer hits on tag bands (Layer.TagHit): comparing two, and bringing one
-//! that left its band back onto it.
-const std = @import("std");
-const pardes = @import("pardes.zig");
-const Layer = @import("Layer.zig");
-const Hit = Layer.TagHit;
-const Kind = Layer.Kind;
-
-/// Whether two pointer hits name the same cell of the same tag line.
-pub fn sameCell(a: ?Hit, b: ?Hit) bool {
- const first = a orelse return b == null;
- const second = b orelse return false;
- return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.line == second.line and first.col == second.col;
-}
-
-/// The tag column a host's pointer is at. Clamped, a point off the tag is
-/// brought back onto line `line` of it, for a drag that leaves it.
-pub fn columnAt(p: *const pardes.Pardes, kind: Kind, id: usize, supplied: ?Hit, clamp: bool, line: u16) ?u16 {
- var point = supplied orelse return null;
- if (point.kind != kind or point.id != id) return null;
- if (clamp) for (p.surface.tagLayers()) |*layer| {
- if (layer.rows == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or line >= layer.rows) continue;
- const bw: f32 = @floatFromInt(point.metrics.body_w);
- const bh: f32 = @floatFromInt(point.metrics.body_h);
- const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
- const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw;
- point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001));
- point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y + line)) + 0.5) * bh;
- break;
- };
- return if (p.reprojectTagHit(point)) |mapped| mapped.col else null;
-}
diff --git a/src/Terminal.zig b/src/terminal.zig
index c99a92a6..c99a92a6 100644
--- a/src/Terminal.zig
+++ b/src/terminal.zig
diff --git a/src/themes/acme.zig b/src/themes/acme.zig
index 621ffa23..277249d3 100644
--- a/src/themes/acme.zig
+++ b/src/themes/acme.zig
@@ -18,6 +18,10 @@
//! acme has no focus colour; pardes needs one, so the focused pane's button
//! is filled #8888cc (the column button's look) and every other pane's is
//! acme's own: #eaffff in a 2px #8888cc ring, #000099 inside it when dirty.
+//!
+//! acme tints nothing in a tag; pardes tints the file's name, a +Name and
+//! the Tty word everywhere, so here it is done quietly, in that same #000099
+//! (draw.h's DMedblue): a navy that reads as ink until you look for it.
pub const theme = .{
.name = "acme",
.bg = .{ 0xff, 0xff, 0xea },
@@ -26,6 +30,7 @@ pub const theme = .{
.sel_fg = .{ 0x00, 0x00, 0x00 },
.tag_bg = .{ 0xea, 0xff, 0xff },
.tag_fg = .{ 0x00, 0x00, 0x00 },
+ .tag_name_fg = .{ 0x00, 0x00, 0x99 },
.box = .{ 0x88, 0x88, 0xcc },
.box_dim = .{ 0xea, 0xff, 0xff },
.kw = .{ 0x97, 0x0d, 0xae },
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index efccd98e..3902271c 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -132,7 +132,14 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
if (carried == buf.len) return error.InputSequenceTooLong;
const received = try tty.read(buf[carried..]);
if (received == 0) return;
- const end = carried + received;
+ // The terminal's answers to frames (FrameAck) come out first,
+ // wherever they sit: never events, and never glued to a key (an
+ // Escape pressed just before one would read as Alt on it).
+ const end = FrameAck.take(buf[0 .. carried + received], loop);
+ if (end == 0) {
+ carried = 0;
+ continue;
+ }
if (kitty_shm_probing.load(.acquire)) if (std.mem.indexOf(u8, buf[0..end], std.fmt.comptimePrint("\x1b_Gi={d};", .{kitty_shm_probe_id}))) |at| {
kitty_shm_probing.store(false, .release);
kitty_shm.store(std.mem.startsWith(u8, buf[at..end], std.fmt.comptimePrint("\x1b_Gi={d};OK", .{kitty_shm_probe_id})), .release);
@@ -153,7 +160,8 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
// its writes whole, so no sequence arrives cut right after its ESC.
// Waiting on it cost every Escape press 50 ms on a legacy terminal
// (tmux, most ssh sessions), and 1 s before kitty's flags were on.
- var waited = carried == 0 and received == 1;
+ // (A terminal's answer to a frame is one write too: FrameAck.)
+ var waited = carried == 0 and end == 1;
while (consumed < parse_end) {
// A lone ESC that ends a longer read is the Escape key, or the
// first byte of a sequence whose rest the read boundary held
@@ -162,6 +170,9 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
// kitty keyboard protocol Escape is `CSI 27 u`, so there the ESC
// can only start a sequence and the wait can be long.
const rest = buf[consumed..parse_end];
+ // A frame's answer cut short by the read waits for its rest.
+ if (parse_end == end and rest.len > 1 and rest.len < FrameAck.answer.len and
+ std.mem.startsWith(u8, FrameAck.answer, rest)) break;
if (!waited and parse_end == end and rest[0] == 0x1b and (rest.len == 1 or (rest.len == 2 and rest[1] == 0x1b))) {
waited = true;
if (try inputFollows(tty, loop.io, if (loop.vaxis.caps.kitty_keyboard) 1000 else 50)) break;
@@ -191,6 +202,63 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
}
}
+/// One frame in flight at most. Each frame the tty draws ends with a device
+/// status request (`CSI 5 n`), and no other frame is drawn until the
+/// terminal's `CSI 0 n` comes back: over ssh the frames a burst of scrolling
+/// or typing would queue behind each other (sshd's window holds megabytes)
+/// are never drawn, and the burst ends one frame and one round trip after
+/// its last input. On a local terminal the answer is back in well under a
+/// millisecond. A terminal that never answers turns this off for good after
+/// `timeout_ns`.
+const FrameAck = struct {
+ const request = "\x1b[5n";
+ const answer = "\x1b[0n";
+ const timeout_ns: u64 = std.time.ns_per_s;
+ var waiting = std.atomic.Value(bool).init(false);
+ var off = std.atomic.Value(bool).init(false);
+ /// When the request went out (the loop's thread only).
+ var sent_ns: u64 = 0;
+
+ /// Take every answer out of `bytes`, closing up the rest; the new
+ /// length. The loop is woken for the frame that waited on one.
+ fn take(bytes: []u8, loop: *Loop) usize {
+ var len = bytes.len;
+ var found = false;
+ while (std.mem.indexOf(u8, bytes[0..len], answer)) |at| {
+ std.mem.copyForwards(u8, bytes[at..], bytes[at + answer.len .. len]);
+ len -= answer.len;
+ found = true;
+ }
+ if (found and waiting.swap(false, .acq_rel)) loop.postEvent(.nop) catch {};
+ return len;
+ }
+
+ /// Whether a new frame must wait (and the loop has a wake at the
+ /// timeout); past the timeout the terminal is taken to never answer.
+ fn holds(now_ns: u64, io: std.Io) bool {
+ if (off.load(.acquire) or !waiting.load(.acquire)) return false;
+ const waited_ns = now_ns -| sent_ns;
+ if (waited_ns >= timeout_ns) {
+ off.store(true, .release);
+ waiting.store(false, .release);
+ return false;
+ }
+ requestWake(io, @intCast((timeout_ns - waited_ns + std.time.ns_per_ms - 1) / std.time.ns_per_ms));
+ return true;
+ }
+
+ /// After a frame that wrote something: ask for the answer.
+ fn ask(w: *std.Io.Writer, now_ns: u64) void {
+ if (off.load(.acquire)) return;
+ // Waiting before the request goes: a local terminal can answer
+ // before the write returns, and an answer nobody waited for is lost.
+ sent_ns = now_ns;
+ waiting.store(true, .release);
+ w.writeAll(request) catch return waiting.store(false, .release);
+ w.flush() catch return waiting.store(false, .release);
+ }
+};
+
/// Whether more input is readable within `ms`, polled in short slices so
/// that a cancel of the input thread is still seen. A test reader answers
/// for itself.
@@ -521,6 +589,98 @@ test "an ESC read by itself is the Escape key at once" {
vx.caps.kitty_keyboard = false;
}
+test "a frame's answer is taken wherever the reads cut it, and never typed" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const io = std.testing.io;
+ var env = try std.testing.environ.createMap(gpa);
+ defer env.deinit();
+ var vx = try vaxis.init(io, gpa, &env, .{});
+ var output: std.Io.Writer.Allocating = .init(gpa);
+ defer output.deinit();
+ defer vx.deinit(gpa, &output.writer);
+ var tty: vaxis.Tty = undefined;
+ var loop: Loop = .init(io, &tty, &vx);
+ var cache: vaxis.GraphemeCache = .{};
+ const Reader = struct {
+ parts: [2][]const u8,
+ next: usize = 0,
+ fn getWinsize(_: *@This()) !vaxis.Winsize {
+ return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 };
+ }
+ fn inputFollows(self: *@This(), _: u32) bool {
+ return self.next < self.parts.len;
+ }
+ fn read(self: *@This(), buf: []u8) !usize {
+ while (self.next < self.parts.len) {
+ const part = self.parts[self.next];
+ self.next += 1;
+ if (part.len == 0) continue;
+ @memcpy(buf[0..part.len], part);
+ return part.len;
+ }
+ return 0;
+ }
+ };
+ const all = "x" ++ FrameAck.answer ++ "y";
+ defer FrameAck.waiting.store(false, .release);
+ for (0..all.len) |cut| {
+ FrameAck.waiting.store(true, .release);
+ var reader: Reader = .{ .parts = .{ all[0..cut], all[cut..] } };
+ try readInput(&loop, &reader, &cache);
+ try std.testing.expect(!FrameAck.waiting.load(.acquire));
+ var typed: std.ArrayList(u8) = .empty;
+ defer typed.deinit(gpa);
+ var woke = false;
+ while (try loop.tryEvent()) |event| switch (event) {
+ .winsize => {},
+ .nop => woke = true,
+ .key_press => |key| try typed.appendSlice(gpa, key.text orelse return error.UnexpectedInputKey),
+ else => return error.UnexpectedInputEvent,
+ };
+ std.testing.expectEqualStrings("xy", typed.items) catch |err| {
+ std.debug.print("cut at {d}\n", .{cut});
+ return err;
+ };
+ try std.testing.expect(woke);
+ }
+ // An Escape pressed just before an answer, in the same read, is the
+ // Escape key, not Alt on the answer.
+ FrameAck.waiting.store(true, .release);
+ var glued: Reader = .{ .parts = .{ "\x1b" ++ FrameAck.answer, "j" } };
+ try readInput(&loop, &glued, &cache);
+ try std.testing.expectEqual(.winsize, std.meta.activeTag((try loop.tryEvent()).?));
+ try std.testing.expectEqual(.nop, std.meta.activeTag((try loop.tryEvent()).?));
+ const escape = (try loop.tryEvent()).?.key_press;
+ try std.testing.expectEqual(vaxis.Key.escape, escape.codepoint);
+ try std.testing.expect(!escape.mods.alt);
+ try std.testing.expectEqual(@as(u21, 'j'), (try loop.tryEvent()).?.key_press.codepoint);
+ try std.testing.expect(try loop.tryEvent() == null);
+}
+
+test "a terminal that never answers a frame stops being asked after one timeout" {
+ const io = std.testing.io;
+ defer {
+ FrameAck.off.store(false, .release);
+ FrameAck.waiting.store(false, .release);
+ FrameAck.sent_ns = 0;
+ }
+ var out: std.Io.Writer.Allocating = .init(std.testing.allocator);
+ defer out.deinit();
+ const t0: u64 = 5 * std.time.ns_per_s;
+ FrameAck.ask(&out.writer, t0);
+ try std.testing.expectEqualStrings(FrameAck.request, out.written());
+ // Unanswered: the next frame waits, until the timeout.
+ try std.testing.expect(FrameAck.holds(t0 + 10 * std.time.ns_per_ms, io));
+ try std.testing.expect(FrameAck.holds(t0 + 999 * std.time.ns_per_ms, io));
+ try std.testing.expect(!FrameAck.holds(t0 + FrameAck.timeout_ns, io));
+ // Then never again: no request, no wait.
+ out.clearRetainingCapacity();
+ FrameAck.ask(&out.writer, t0 + 2 * std.time.ns_per_s);
+ try std.testing.expectEqual(@as(usize, 0), out.written().len);
+ try std.testing.expect(!FrameAck.holds(t0 + 2 * std.time.ns_per_s, io));
+}
+
test "terminal input cancellation joins blocked reads and queued EOF" {
if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
const gpa = std.testing.allocator;
@@ -1438,6 +1598,12 @@ const Shell = struct {
const s = of(ctx);
const vx = s.vx;
s.tracks = canonical.panelTracks();
+ // The last frame is not answered yet: this one stays owed, and the
+ // answer's wake draws the state as it is then.
+ if (FrameAck.holds(host_io.monotonicNs(), s.io)) {
+ s.core.present_skipped = true;
+ return;
+ }
_ = s.frame.reset(.retain_capacity);
const surface = panel_compositor.compose(
s.frame.allocator(),
@@ -1453,7 +1619,7 @@ const Shell = struct {
const shadowed = chipShadows(s.frame.allocator(), surface, s.core, truecolor) catch surface;
const flashed = s.flash.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed;
const trailed = s.trail.apply(s.frame.allocator(), flashed, s.core, truecolor) catch flashed;
- paintCells(vx, win, trailed.cells, trailed.cols, trailed.rows);
+ paintCells(win, trailed.cells, trailed.cols, trailed.rows);
tz_cells.end();
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
@@ -1523,7 +1689,9 @@ const Shell = struct {
}
if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar);
const tz_render = tracy.zone(@src(), "vx.render");
+ const before = s.tty.tty_writer.pos + s.tty.writer().end;
vx.render(s.tty.writer()) catch {};
+ if (s.tty.tty_writer.pos + s.tty.writer().end != before) FrameAck.ask(s.tty.writer(), host_io.monotonicNs());
tz_render.end();
}
@@ -1747,7 +1915,7 @@ test "TTY queued results reject old PTY generations and LSP request IDs" {
shell.gens[0] = 2;
_ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 1, .bytes = try gpa.dupe(u8, "old session\n") } });
_ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 2, .bytes = try gpa.dupe(u8, "current session\n") } });
- const text = try pardes.panes.Terminal.screenTextAlloc(core.panes[0].?, gpa);
+ const text = try pardes.panes.terminal.screenTextAlloc(core.panes[0].?, gpa);
defer gpa.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "old session") == null);
try std.testing.expect(std.mem.indexOf(u8, text, "current session") != null);
@@ -2170,94 +2338,26 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
} };
}
-fn paintCells(vx: *const vaxis.Vaxis, win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void {
+/// A blank whose ink alone changed is not sent: vaxis's render skips it
+/// (pardes's patch to Vaxis.zig in build.zig), since a blank's ink shows
+/// nowhere. Deciding that here cost every cell of every frame a look at the
+/// frame before; vaxis already makes that comparison.
+fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void {
win.clear();
var y: u16 = 0;
while (y < rows) : (y += 1) {
- const row = cells[@as(usize, y) * cols ..][0..cols];
var x: u16 = 0;
- // The cell to the left as written, for a blank that needs it.
- var left: ?Written = null;
while (x < cols) : (x += 1) {
- const cell = &row[x];
- if (cell.default) {
- left = null;
- continue;
- }
- const grapheme = cell.grapheme();
- var style = vaxisStyle(cell.style);
- if (inkless(grapheme, style)) blankInk(vx, row, x, y, &style, left);
- left = .{ .grapheme = grapheme, .style = style };
- win.writeCell(x, y, .{ .char = .{ .grapheme = grapheme }, .style = style });
+ const cell = &cells[@as(usize, y) * cols + x];
+ if (cell.default) continue;
+ win.writeCell(x, y, .{
+ .char = .{ .grapheme = cell.grapheme() },
+ .style = vaxisStyle(cell.style),
+ });
}
}
}
-const Written = struct { grapheme: []const u8, style: vaxis.Style };
-
-/// A blank's ink shows nowhere (no underline, strike or reverse to show it).
-fn inkless(grapheme: []const u8, style: vaxis.Style) bool {
- return std.mem.eql(u8, grapheme, " ") and !style.reverse and !style.strikethrough and style.ul_style == .off;
-}
-
-/// A blank whose ink alone changed (a fade: InactiveDim on a focus change)
-/// keeps the ink the terminal has there and so is not sent at all:
-/// otherwise every blank of every pane a fade touched went, 7 KB a click
-/// for three shells. Only a short gap between two cells that are sent
-/// anyway is cheaper sent than jumped (a cursor move): it takes the ink of
-/// the cell before it, so the run needs no colour change either.
-fn blankInk(vx: *const vaxis.Vaxis, row: []const pardes.Cell, x: u16, y: u16, style: *vaxis.Style, left: ?Written) void {
- const last = lastCell(vx, x, y) orelse return;
- if (vx.refresh or last.default or last.skipped or !std.mem.eql(u8, last.char.items, " ")) return;
- if (sameColor(last.style.fg, style.fg) or !sameButInk(last.style, style.*)) return;
- var kept = style.*;
- kept.fg = last.style.fg;
- if (left) |l| if (sends(vx, x - 1, y, l.grapheme, l.style)) {
- var end = x + 1;
- while (end < row.len and end - x < 8) : (end += 1) {
- const next = &row[end];
- if (next.default) break;
- const next_style = vaxisStyle(next.style);
- if (inkless(next.grapheme(), next_style)) continue;
- if (sends(vx, end, y, next.grapheme(), next_style)) {
- style.fg = l.style.fg;
- return;
- }
- break;
- }
- };
- style.* = kept;
-}
-
-/// `Style.eql` but for the ink, and cheap: this runs for every blank of
-/// every frame.
-fn sameButInk(a: vaxis.Style, b: vaxis.Style) bool {
- return sameColor(a.bg, b.bg) and sameColor(a.ul, b.ul) and a.ul_style == b.ul_style and
- a.bold == b.bold and a.dim == b.dim and a.italic == b.italic and a.blink == b.blink and
- a.reverse == b.reverse and a.invisible == b.invisible and a.strikethrough == b.strikethrough;
-}
-
-fn sameColor(a: vaxis.Color, b: vaxis.Color) bool {
- return switch (a) {
- .default => b == .default,
- .index => |i| b == .index and b.index == i,
- .rgb => |rgb| b == .rgb and std.mem.eql(u8, &rgb, &b.rgb),
- };
-}
-
-/// Whether vaxis sends this cell: it differs from what the terminal shows.
-fn sends(vx: *const vaxis.Vaxis, x: u16, y: u16, grapheme: []const u8, style: vaxis.Style) bool {
- const last = lastCell(vx, x, y) orelse return true;
- return vx.refresh or last.default or last.skipped or !std.mem.eql(u8, last.char.items, grapheme) or
- !sameColor(last.style.fg, style.fg) or !sameButInk(last.style, style);
-}
-
-fn lastCell(vx: *const vaxis.Vaxis, x: u16, y: u16) ?*const vaxis.AllocatingScreen.InternalCell {
- if (x >= vx.screen.width) return null;
- const i = @as(usize, y) * vx.screen.width + x;
- return if (i < vx.screen_last.buf.len) &vx.screen_last.buf[i] else null;
-}
-
test "a fade sends the text it dims, not the blanks beside it" {
const gpa = std.testing.allocator;
var env = try std.testing.environ.createMap(gpa);
@@ -2267,8 +2367,8 @@ test "a fade sends the text it dims, not the blanks beside it" {
defer out.deinit();
defer vx.deinit(gpa, &out.writer);
try vx.resize(gpa, &out.writer, .{ .rows = 1, .cols = 32, .x_pixel = 0, .y_pixel = 0 });
- // `a`, a long gap, `b`, a two-cell gap, `c`, a long gap, an underlined
- // blank (which shows its ink) and one more blank.
+ // `a`, a gap, `b`, a two-cell gap, `c`, a gap, an underlined blank
+ // (which shows its ink) and one more blank.
var cells: [32]pardes.Cell = @splat(.{ .default = false });
cells[0].text[0] = 'a';
cells[12].text[0] = 'b';
@@ -2279,16 +2379,16 @@ test "a fade sends the text it dims, not the blanks beside it" {
for ([_][3]u8{ .{ 200, 200, 200 }, .{ 90, 90, 90 } }, 0..) |ink, frame| {
for (&cells) |*cell| cell.style.fg = .{ .rgb = ink };
out.clearRetainingCapacity();
- paintCells(&vx, vx.window(), &cells, 32, 1);
+ paintCells(vx.window(), &cells, 32, 1);
try vx.render(&out.writer);
sent[frame] = try out.toOwnedSlice();
}
// Against a cleared screen every cell goes. After the fade the letters
- // go, the short gap between two of them (cheaper than a cursor move),
- // and the underlined blank; the long gaps are jumped.
+ // go and the underlined blank; every plain blank is jumped.
try std.testing.expectEqual(@as(usize, 29), std.mem.count(u8, sent[0], " "));
- try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, sent[1], " "));
- try std.testing.expect(std.mem.indexOf(u8, sent[1], "b c") != null);
+ try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, sent[1], " "));
+ try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'b') != null);
+ try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'c') != null);
try std.testing.expect(std.mem.indexOf(u8, sent[1], "90:90:90") != null);
try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'a') != null);
}
@@ -2688,7 +2788,7 @@ const Attach = struct {
fn paint(a: *Attach) void {
a.dirty = false;
const win = a.vx.window();
- paintCells(a.vx, win, a.client.grid.items, a.client.cols, a.client.rows);
+ paintCells(win, a.client.grid.items, a.client.cols, a.client.rows);
if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar);
a.vx.render(a.tty.writer()) catch {};
}
diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig
index eaa19b43..9e67c49f 100644
--- a/test/e2e_harness.zig
+++ b/test/e2e_harness.zig
@@ -64,6 +64,8 @@ pub const Harness = struct {
/// when true, print the captured screen state after each pump/waitFor and on
/// every assertion, so live test runs can be inspected (zig build test -Dtrace).
trace: bool = false,
+ /// How much of a `CSI 5 n` the output has ended on (`feed`).
+ status_request: u8 = 0,
/// every raw byte the app has emitted (accumulated in pump). Lets tests assert
/// on control sequences the emulator consumes and never renders (e.g. OSC 52
/// clipboard writes). Capture is bounded explicitly so a runaway child cannot
@@ -147,6 +149,24 @@ pub const Harness = struct {
/// Read pty output and feed it to our ghostty terminal for `ms` ms. After
/// this, the grid reflects everything the app rendered so far.
+ /// The app's output into the emulator. The one query answered is the
+ /// device status request (`CSI 5 n` → `CSI 0 n`), as every terminal
+ /// does: the tty asks it after each frame and draws no other until the
+ /// answer (FrameAck in src/tty/tty.zig).
+ fn feed(self: *Harness, bytes: []const u8) void {
+ self.stream.nextSlice(bytes);
+ const request = "\x1b[5n";
+ for (bytes) |b| {
+ if (b == request[self.status_request]) {
+ self.status_request += 1;
+ } else self.status_request = if (b == 0x1b) 1 else 0;
+ if (self.status_request == request.len) {
+ self.status_request = 0;
+ _ = libc.write(self.master, "\x1b[0n", 4);
+ }
+ }
+ }
+
pub fn pump(self: *Harness, ms: i64) !void {
const deadline = nowMs() + ms;
var buf: [4096]u8 = undefined;
@@ -157,7 +177,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
}
}
if (self.trace) self.traceScreen("pump");
@@ -176,7 +196,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch return false;
if (n == 0) return false;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
return true;
}
@@ -243,7 +263,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
}
const text = try self.screenText();
defer self.gpa.free(text);
@@ -270,7 +290,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
if (std.mem.indexOf(u8, self.raw.items, needle) != null) return true;
}
return false;
diff --git a/test/fs.py b/test/fs.py
index db10f32d..06643385 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -953,6 +953,20 @@ def test(binary, quic=False):
with Client(address, msize=256) as small:
assert set(small.list('/os' + str(listing))) == {
f'entry-{number:02}' for number in range(24)}
+ # 64 KiB frames: a ctl line with no newline, whole in its one
+ # Twrite, runs with it however long -- the cutoff for a write
+ # that may go on is the frame the client asked for, less 24.
+ with Client(address, msize=65536) as big:
+ assert big.msize == 65536
+ ctl = big.open('/pane/1/ctl', 1)
+ line = b'bogus' + b'x' * 20000
+ try:
+ big.rpc(118, struct.pack('<IQI', ctl, 0, len(line)) + line)
+ except OSError:
+ pass # refused with its write (a line over 1024 bytes), not held
+ else:
+ raise AssertionError('a whole 20000-byte line waited for a newline')
+ big.close(ctl)
frozen = client.open('/screen')
before = bytearray(client.read_fid(frozen, count=31))
@@ -1261,10 +1275,43 @@ def test(binary, quic=False):
assert time.monotonic() - deleted < .05, time.monotonic() - deleted
assert second.wait(timeout=5) == 0
assert edited.read_bytes() == b'edited\n'
+ # A name not there yet opens a pane named it, as acme's B does,
+ # and Save creates it: git's and fish's temporary files are.
+ fresh = root / 'wait new.txt'
+ before = serials()
+ made = subprocess.Popen([binary, '--wait', fresh.name], cwd=root, env=env,
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
+ # The launch makes the pane (pane/new), then names it: wait for
+ # the name, not the pane.
+ deadline = time.monotonic() + 3
+ while str(fresh).encode() not in client.read('/index'):
+ assert made.poll() is None, made.stderr.read()
+ assert time.monotonic() < deadline, 'a new name opened no pane named it'
+ time.sleep(.01)
+ named, = serials() - before
+ client.write(f'/pane/{named}/body', b'new\n')
+ execute(client, named, 'Save')
+ client.remove(f'/pane/{named}')
+ assert made.wait(timeout=5) == 0
+ assert fresh.read_bytes() == b'new\n'
orphan = launch('--wait')
assert orphan.poll() is None
assert orphan.wait(timeout=5) == 1
+ # A launch with no terminal to draw on (a pty that is no one's
+ # controlling terminal) says so and exits 1, no error trace.
+ master, slave = os.openpty()
+ try:
+ env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')}
+ env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), TERM='xterm-256color')
+ result = subprocess.run([binary, '--tty', 'x.txt'], cwd=root, env=env, stdin=slave, stdout=slave,
+ stderr=subprocess.PIPE, start_new_session=True, timeout=10)
+ finally:
+ os.close(master)
+ os.close(slave)
+ assert result.returncode == 1, result
+ assert b'pardes: no terminal to draw on' in result.stderr and b'.zig:' not in result.stderr, result.stderr
+
for tty in [False, True]:
name = 'lsp-tty' if tty else 'lsp-detached'
with session(binary, root, name, tty=tty) as (client, _):
diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt
index 2b364b37..2d3c1a77 100644
--- a/test/gui-goldens.txt
+++ b/test/gui-goldens.txt
@@ -11,7 +11,7 @@
12-multiline-tag ddd7d775a246e91b88f0eff98b6f9bea8aa5d63dbfbe931bd830ccb9086bd5c4
13-image f609043fc0fcdb55369b836b61fcbc631f786cc2295a5ddd72c0d9a59b177a60
14-theme-ink 881b7e99f38ad4aa3c46d2c61e20598010175d7858712a67fa6638fc29f12e14
-15-theme-acme-light ca5252f5117ac8d6c679cef917c9f316c9ca311fc7a8dcbac2796b4fc04d3b64
-16-debug 591bb7c45b4b9c46cd2458330b4fa3962afc6dac827fa6f5e20e927a72c586e6
-17-terminal 38299427891e31de0b5597159501c161474620eb26fd7708a6396f0fd685209e
-18-mid-transition 5d3e37d874988b460297e18df9508a77b602d5f675e28983c2c793871d4301f6
+15-theme-acme-light 9648633a897416b14849e0dbb857a98d4c4b7d46aa0e799ab5e302fbe240652f
+16-debug be4d09d67b4293dd038bca57960d77d65d1f9f0281433e9cb9cbba654418fc15
+17-terminal 440cc30bb1f5086e572840f6ccd211dda1e266d77fe02f997e51d92f52494d3c
+18-mid-transition e1f6459cb918050e4f4e8cd485d924f022e75bc19d80c79f710117b18168e8e8
diff --git a/test/gui_monkey.py b/test/gui_monkey.py
new file mode 100644
index 00000000..2b32f050
--- /dev/null
+++ b/test/gui_monkey.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""A monkey over the GUI's effects: for each Motion x Lift x Bloom, a hidden
+test window gets a random burst of clicks, drags, wheels, keys, new and
+joined columns, opened files and theme changes, then must settle (its
+frames stop changing) with no crash record and its process alive.
+
+gui_monkey.py <pardes-gui> [outdir] [--seed N] [--steps N]; `zig build
+monkey-gui -Dplatform=gui -- [--seed N] [--steps N]` runs it on the built
+window. Exits 1 when any combination failed; what failed is in outdir.
+"""
+import argparse
+import hashlib
+import itertools
+import os
+import random
+import shutil
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from fs import session # noqa: E402
+
+MOTIONS = ['off', 'crisp', 'smooth', 'bouncy', 'playful']
+LIFTS = ['off', 'shadow', 'rim', 'auto']
+BLOOMS = ['0', '2']
+THEMES = ['lapis', 'forge', 'acme', 'dusk', 'daybreak']
+W, H = 1280, 840
+
+
+def settled(cap, timeout=20):
+ latest = cap / 'latest.ppm'
+ previous = None
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ time.sleep(.4)
+ try:
+ data = latest.read_bytes()
+ except FileNotFoundError:
+ continue
+ digest = hashlib.sha256(data).hexdigest()
+ if digest == previous:
+ return True
+ previous = digest
+ return False
+
+
+def run(binary, out, motion, lift, bloom, seed, steps):
+ rng = random.Random(seed)
+ root = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-'))
+ cap = root / 'cap'
+ cap.mkdir()
+ (root / 'config/pardes').mkdir(parents=True)
+ (root / 'config/pardes/init').write_text(
+ f'Shell /bin/sh\nMessageLinger 0\nMotion {motion}\nLift {lift}\nBloom {bloom}\nPanelSlide\nSelectionGlow on\nHoverGlow on\n')
+ src = root / 'a.zig'
+ src.write_text(''.join(f'pub fn f{n}(x: u32) u32 {{ return x * {n}; }} // a line\n' for n in range(200)))
+ for name in ('b.txt', 'c.txt'):
+ (root / name).write_text(''.join(f'{name} line {n} with some words\n' for n in range(80)))
+ display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / os.environ.get('WAYLAND_DISPLAY', 'wayland-0')
+ env = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '80', 'PARDES_TEST_ROWS': '28', 'PARDES_TEST_PAD': '0',
+ 'PARDES_TEST_CAPTURE_DIR': str(cap), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)}
+ name = 'monkey'
+ launch = ['-c', 'stty cols 80 rows 28; exec "$@"', name, str(binary.resolve()), '--9p=' + name, str(src)]
+ handles = {}
+ verdict = 'ok'
+ log = []
+ try:
+ with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, inherited=env, terminal=handles) as (client, _):
+ fd = handles['input_fd']
+ time.sleep(1.0)
+
+ def mouse(kind, button, x, y):
+ os.write(fd, f'\x1b]777;mouse;{kind};{button};{x};{y}\x07'.encode())
+
+ def first():
+ rows = client.read('/index').decode().splitlines()
+ return int(rows[0].split()[0]) if rows else None
+
+ for _ in range(steps):
+ x, y = rng.randrange(W), rng.randrange(H)
+ action = rng.randrange(12)
+ try:
+ if action in (0, 1):
+ mouse('down', 1, x, y); mouse('up', 1, x, y); log.append(f'click {x},{y}')
+ elif action == 2:
+ x2, y2 = rng.randrange(W), rng.randrange(H)
+ mouse('down', 1, x, y)
+ for i in range(1, 6):
+ mouse('motion', 1, x + (x2 - x) * i // 5, y + (y2 - y) * i // 5)
+ mouse('up', 1, x2, y2); log.append(f'drag {x},{y}->{x2},{y2}')
+ elif action == 3:
+ amount = rng.choice([-6, -2, 2, 6])
+ os.write(fd, f'\x1b]777;mouse;wheel;{amount};{x};{y}\x07'.encode()); log.append(f'wheel {amount}')
+ elif action == 4:
+ mouse('motion', 0, x, y); log.append(f'hover {x},{y}')
+ elif action == 5:
+ client.write('/ctl', b'Newcol\n'); log.append('Newcol')
+ elif action == 6:
+ serial = first()
+ if serial is not None:
+ target = root / rng.choice(['b.txt', 'c.txt', 'a.zig'])
+ client.write(f'/pane/{serial}/look', f'{target}\n'.encode()); log.append(f'look {target.name}')
+ elif action == 7:
+ serial = first()
+ if serial is not None:
+ # Delcol too: the last column's going leaves an
+ # empty window now (acme's), not a quit.
+ word = rng.choice(['Joincol', 'Collapse', 'Delcol'])
+ try:
+ client.write(f'/pane/{serial}/exec', (word + '\n').encode())
+ except OSError:
+ pass
+ log.append(word)
+ else:
+ # An empty window: pane/new makes its column and a pane.
+ try:
+ client.read('/pane/new')
+ except OSError:
+ pass
+ log.append('pane/new (empty window)')
+ elif action == 8:
+ os.write(fd, rng.choice([b'j', b'k', b'10j', b'gg', b'w', b'%', b'v5j', b'\x1b']))
+ log.append('keys')
+ elif action == 9:
+ theme = rng.choice(THEMES)
+ client.write('/ctl', f'Theme {theme}\n'.encode()); log.append(f'Theme {theme}')
+ elif action == 10:
+ mouse('down', 3, x, y); mouse('up', 3, x, y); log.append(f'look-click {x},{y}')
+ else:
+ time.sleep(rng.choice([0, .05, .2])); log.append('pause')
+ except OSError as why:
+ log.append(f'refused: {why}')
+ time.sleep(.03)
+ if not settled(cap):
+ verdict = 'never settled'
+ try:
+ os.kill(handles['pid'], 0)
+ except OSError:
+ verdict = 'process gone'
+ client.read('/index')
+ except Exception as why: # noqa: BLE001
+ verdict = f'failed: {type(why).__name__}: {str(why)[:300]}'
+ crashes = list((root / 'config').rglob('*crash*'))
+ if crashes:
+ verdict = 'crash record: ' + ', '.join(str(c) for c in crashes)
+ label = f'{motion}-{lift}-bloom{bloom}'
+ if verdict != 'ok':
+ (out / f'{label}.log').write_text('\n'.join(log) + '\n' + verdict + '\n')
+ for c in crashes:
+ shutil.copy(c, out / f'{label}-{c.name}')
+ if (cap / 'latest.ppm').exists():
+ shutil.copy(cap / 'latest.ppm', out / f'{label}-last.ppm')
+ shutil.rmtree(root, ignore_errors=True)
+ return label, verdict
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('binary', type=Path)
+ ap.add_argument('out', type=Path, nargs='?')
+ ap.add_argument('--seed', type=int, default=1)
+ ap.add_argument('--steps', type=int, default=60)
+ args = ap.parse_args()
+ if args.out is None:
+ args.out = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-out-'))
+ args.out.mkdir(parents=True, exist_ok=True)
+ bad = 0
+ for n, (motion, lift, bloom) in enumerate(itertools.product(MOTIONS, LIFTS, BLOOMS)):
+ label, verdict = run(args.binary, args.out, motion, lift, bloom, args.seed * 1000 + n, args.steps)
+ print(f'{label}: {verdict}', flush=True)
+ bad += verdict != 'ok'
+ print(f'{bad} of {len(MOTIONS) * len(LIFTS) * len(BLOOMS)} combinations failed; logs in {args.out}', flush=True)
+ sys.exit(1 if bad else 0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/mode.zig b/test/mode.zig
index cba93c30..767cda93 100644
--- a/test/mode.zig
+++ b/test/mode.zig
@@ -26,7 +26,7 @@ fn customTag(pane: *pardes.Pane, text: []const u8) !void {
}
test "Mode cycles terminal modes and keeps legacy toggle and custom tags" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100 });
defer p.deinit();
p.presentation.enabled = false;
diff --git a/test/monkey9p.py b/test/monkey9p.py
index c0e99fb8..ee0b0ebc 100644
--- a/test/monkey9p.py
+++ b/test/monkey9p.py
@@ -641,11 +641,11 @@ def op_write(sess, op):
ok, body = w.call(TOPEN, struct.pack('<IB', fid, mode), timeout)
res.add(open_kind(path, mode), path, ok, None if ok else body)
sizes = chunking(op.get('chunks', 'whole'), len(data), w.msize - 23)
- allowances(res, path, data, sizes, w.msize - 24)
opened = ok
sent = data
if ok:
offset = 0
+ took = []
for size in sizes:
piece = data[offset:offset + size]
ok, body = w.call(TWRITE, struct.pack('<IQI', fid, op.get('offset', offset), len(piece)) + piece, timeout)
@@ -654,8 +654,13 @@ def op_write(sess, op):
res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body)
offset += size
sent = data[:offset]
+ if ok:
+ took.append(size)
if not ok and op.get('stop_on_error', True):
break
+ # Misses are allowed the Twrites that succeeded: a line written a
+ # byte a Twrite is a look a byte, each its own write.
+ allowances(res, path, data[:sum(took)], took, w.msize - 24)
ok, body = w.call(TCLUNK, struct.pack('<I', fid), timeout)
res.add('clunk-write' if mode & 3 else 'clunk', path, ok, None if ok else body)
# What the open holds when it closes: the last line written, if unended.
@@ -850,11 +855,12 @@ def op_hwrite(sess, op):
fid, path, mode, off, _ = h
data = dec(op['data'])[:sess.wire.msize - 23] # one Twrite
h[4] = data
- allowances(res, path, data, [len(data)], sess.wire.msize - 24)
ok, body = sess.wire.call(TWRITE, struct.pack('<IQI', fid, off[0], len(data)) + data,
SLOW_TIMEOUT if slow(data) else TIMEOUT)
res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body)
if ok:
+ allowances(res, path, data, [len(data)], sess.wire.msize - 24)
+ if ok:
off[0] += len(data)
return res
@@ -1498,9 +1504,9 @@ def one_failure_rule(ctx, res):
and w[1].rstrip('/') != '/pane/new']
errs = occurrences(res.window, 'err')
msgs = occurrences(res.window, 'msg')
- # Only for a write that succeeded: a failed one logs its one err.
- failed_paths = {w[1] for w in failed}
- allowed = sum(r[3] for r in res.requests if r[0] == 'allow' and r[1] not in failed_paths)
+ # Allowed only the Twrites that succeeded (allowances): a failed one
+ # logs its one err.
+ allowed = sum(r[3] for r in res.requests if r[0] == 'allow')
# fs.md 'A write of command lines ... what is left when it closes runs at
# the close ... and its failure is in the log alone'.
closes = [r for r in res.requests if r[0] == 'close-runs']
diff --git a/test/output.zig b/test/output.zig
index e5f87fb7..1b1b80c9 100644
--- a/test/output.zig
+++ b/test/output.zig
@@ -472,7 +472,7 @@ test "Mini syntax colors survive toggles themes and rendering without source acc
defer p.deinit();
try p.panes[0].?.setOwnedCwd(dir);
p.settings.colors = false;
- try panes.Mini.open(p, 0, "demo.zig");
+ try panes.mini.open(p, 0, "demo.zig");
const id = p.active;
const mini = p.panes[id].?;
const keyword = std.mem.indexOfScalar(u8, mini.file.?.mini.?.colors, @intFromEnum(pardes.syntax.Syn.keyword)) orelse return error.MissingKeywordColor;
@@ -522,12 +522,12 @@ test "Mini failures leave its existing snapshot and focus unchanged" {
const source = try p.setTestFile("abc\n");
var path_buf: [128]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{source.serial});
- try panes.Mini.open(p, 0, path);
+ try panes.mini.open(p, 0, path);
const id = p.active;
const mini = p.panes[id].?;
const body = mini.file.?.content.ptr;
const next = p.freeSlot();
- try std.testing.expectError(error.FileNotFound, panes.Mini.open(p, 0, "/virtual/does-not-exist"));
+ try std.testing.expectError(error.FileNotFound, panes.mini.open(p, 0, "/virtual/does-not-exist"));
try std.testing.expectEqual(id, p.active);
try std.testing.expectEqual(body, mini.file.?.content.ptr);
try std.testing.expectEqual(next, p.freeSlot());
diff --git a/test/panes.zig b/test/panes.zig
index c1ae1666..f7ca9fc5 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -864,7 +864,7 @@ const TtySelectionTests = struct {
}
test "tty selection yields to a new modal cursor selection" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
defer p.deinit();
p.presentation.enabled = false;
@@ -889,7 +889,7 @@ const TtySelectionTests = struct {
}
test "tty selection preserves its exact stream through yank clipboard and mode changes" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
for ([_]bool{ false, true }) |prompt| {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
defer p.deinit();
@@ -949,7 +949,7 @@ const TtySelectionTests = struct {
}
test "tty selection pastes across panes once with existing bracket and mouse protocols" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
for ([_]bool{ false, true }) |bracketed| {
for ([_]bool{ false, true }) |reports_mouse| {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 24 });
@@ -1028,14 +1028,14 @@ const TtySelectionTests = struct {
// than the text goes and reads the desktop clipboard on its own account,
// and a coding agent reading it is looking for an image, not for words.
test "raw tty paste chords type at the program instead of reaching it as keys" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
for ([_]bool{ false, true }) |bracketed| {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
defer p.deinit();
const pane = p.panes[0].?;
try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode);
if (bracketed) p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } });
- try std.testing.expectEqual(bracketed, panes.Terminal.bracketedPaste(pane));
+ try std.testing.expectEqual(bracketed, panes.terminal.bracketedPaste(pane));
p.registers.put(p.gpa, '"', "one\ntwo", 0, 1, true); // what a `y` anywhere left behind
var buf: [256]u8 = undefined;
_ = childInput(p, &buf);
@@ -1554,7 +1554,7 @@ const TagNameTintTests = struct {
try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().sel_fg }, surface.at(x + 1, y).style.fg);
}
- test "tag filename tint legacy themes retain distinct active foreground" {
+ test "tag filename tint legacy themes keep their active foreground and tint the name with a hue of their own" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 70, .rows = 14 });
defer p.deinit();
const pane = try p.setTestFile("body\n");
@@ -1574,8 +1574,12 @@ const TagNameTintTests = struct {
const x = p.rects[0].x + pardes.TAG_TEXT_INSET;
const y = tagY(p, p.rects[0]);
const fg = if (active) active_fg else tag_fg;
+ // The directory in the ink; the name tinted, never in it.
+ const chrome = p.chromeTheme();
+ const tint = if (active) chrome.tag_active_name_fg else chrome.tag_name_fg;
+ try std.testing.expect(!std.meta.eql(tint, fg));
try std.testing.expectEqual(pardes.Color{ .rgb = fg }, surface.at(x + 1, y).style.fg);
- try std.testing.expectEqual(pardes.Color{ .rgb = fg }, surface.at(x + 5, y).style.fg);
+ try std.testing.expectEqual(pardes.Color{ .rgb = tint }, surface.at(x + 5, y).style.fg);
}
}
};
@@ -1829,7 +1833,7 @@ test "terminal overlay recoloring never materializes unrelated history" {
_ = try p.render(frame.allocator());
try testing.expect(p.shell_rows.pane == null);
- pane.ovl.?.row = panes.Terminal.gridOffset(pane);
+ pane.ovl.?.row = panes.terminal.gridOffset(pane);
p.gpa.free(pane.ovl.?.text);
pane.ovl.?.text = try p.gpa.dupe(u8, "caf\xc3\xa9 \xe7\x95\x8c");
_ = frame.reset(.retain_capacity);
@@ -1843,7 +1847,7 @@ test "terminal overlay recoloring never materializes unrelated history" {
}
test "terminal output keeps following new rows after scrollback eviction" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
defer p.deinit();
@@ -1859,9 +1863,9 @@ test "terminal output keeps following new rows after scrollback eviction" {
for (0..3) |phase| {
if (phase > 0) {
pane.body.mode = .normal;
- panes.Terminal.scrollGrid(pane, -30);
+ panes.terminal.scrollGrid(pane, -30);
try std.testing.expect(pages.scrollbar().offset < pages.scrollbar().total - pane.rows);
- panes.Terminal.enterTty(p, 0);
+ panes.terminal.enterTty(p, 0);
}
for (phase * 20_000..(phase + 1) * 20_000) |n| {
var buf: [64]u8 = undefined;
@@ -1874,7 +1878,7 @@ test "terminal output keeps following new rows after scrollback eviction" {
try std.testing.expect(pages.page_size <= pages.maxSize());
const sb = pages.scrollbar();
try std.testing.expectEqual(sb.total - pane.rows, sb.offset);
- const full = try panes.Terminal.screenTextAlloc(pane, gpa);
+ const full = try panes.terminal.screenTextAlloc(pane, gpa);
defer gpa.free(full);
var last_buf: [16]u8 = undefined;
const last = try std.fmt.bufPrint(&last_buf, "row-{d:0>5}", .{(phase + 1) * 20_000 - 1});
@@ -1904,7 +1908,7 @@ test "terminal output keeps following new rows after scrollback eviction" {
}
test "terminal edit stays on its surviving row when old scrollback is evicted" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
defer p.deinit();
@@ -1942,7 +1946,7 @@ test "terminal edit stays on its surviving row when old scrollback is evicted" {
}
}
try std.testing.expect(removed > 0 and removed < original_row);
- const full = try panes.Terminal.screenTextAlloc(pane, gpa);
+ const full = try panes.terminal.screenTextAlloc(pane, gpa);
defer gpa.free(full);
const row = original_row - @as(i32, @intCast(removed));
try std.testing.expectEqualStrings(last, modal.lineSlice(full, @intCast(row)));
@@ -1952,7 +1956,7 @@ test "terminal edit stays on its surviving row when old scrollback is evicted" {
}
test "terminal edits and undo survive partial history clearing and whole-screen loss" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
const gpa = std.testing.allocator;
const Action = enum { clear_history, partial_clear_history, reset_primary, reset_alternate, evict_all, hidden_undo };
for (std.enums.values(Action)) |action| {
@@ -1974,7 +1978,7 @@ test "terminal edits and undo survive partial history clearing and whole-screen
pane.body.cur_col = 4;
pane.body.cur_pinned = true;
pane.body.vsel = .{ .active = true, .row = row, .col = 1 };
- panes.Terminal.pushUndo(p, pane);
+ panes.terminal.pushUndo(p, pane);
const overlay = pane.ovl.?;
pane.body.ed_redo[0] = .{
.ovl = .{ .row = overlay.row, .rows = overlay.rows, .text = try gpa.dupe(u8, overlay.text) },
@@ -1986,7 +1990,7 @@ test "terminal edits and undo survive partial history clearing and whole-screen
if (action == .hidden_undo) {
gpa.free(pane.ovl.?.text);
pane.ovl = null;
- panes.Terminal.enterTty(p, 0);
+ panes.terminal.enterTty(p, 0);
}
const bytes = switch (action) {
.clear_history, .partial_clear_history => "\x1b[3J",
@@ -2002,20 +2006,20 @@ test "terminal edits and undo survive partial history clearing and whole-screen
try std.testing.expectEqual(expected + 1, pane.body.cur_row);
try std.testing.expectEqual(expected, pane.body.vsel.row);
}
- for ([_]panes.Terminal.Snapshot{ pane.body.ed_undo[0], pane.body.ed_redo[0] }) |snapshot| {
+ for ([_]panes.terminal.Snapshot{ pane.body.ed_undo[0], pane.body.ed_redo[0] }) |snapshot| {
try std.testing.expectEqual(expected, snapshot.ovl.?.row);
try std.testing.expectEqual(expected + 1, snapshot.cur_row);
try std.testing.expectEqualStrings("edit one\nedit two", snapshot.ovl.?.text);
}
try std.testing.expectEqual(@as(usize, 2), pane.terminal.?.vt.screens.active.pages.countTrackedPins());
- panes.Terminal.undo(p, pane);
+ panes.terminal.undo(p, pane);
try std.testing.expectEqualStrings("edit one\nedit two", pane.ovl.?.text);
try std.testing.expectEqual(expected, pane.ovl.?.row);
}
}
test "terminal lower scroll regions do not move edits above them" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
defer p.deinit();
@@ -2026,23 +2030,23 @@ test "terminal lower scroll regions do not move edits above them" {
pane.ovl = .{ .row = 1, .rows = 1, .text = try gpa.dupe(u8, "unsent") };
pane.body.cur_row = 1;
pane.body.cur_pinned = true;
- panes.Terminal.pushUndo(p, pane);
- const total = panes.Terminal.scrollbar(pane).total;
+ panes.terminal.pushUndo(p, pane);
+ const total = panes.terminal.scrollbar(pane).total;
var setup: [64]u8 = undefined;
const bytes = try std.fmt.bufPrint(&setup, "\x1b[4;{d}r\x1b[{d};1H", .{ pane.rows, pane.rows });
p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
p.update(.{ .output = .{ .pane = 0, .bytes = "tail\r\n" ** 50 } });
- try std.testing.expectEqual(total, panes.Terminal.scrollbar(pane).total);
+ try std.testing.expectEqual(total, panes.terminal.scrollbar(pane).total);
try std.testing.expectEqual(@as(i32, 1), pane.ovl.?.row);
try std.testing.expectEqual(@as(i32, 1), pane.body.cur_row);
try std.testing.expectEqual(@as(i32, 1), pane.body.ed_undo[0].ovl.?.row);
- const full = try panes.Terminal.screenTextAlloc(pane, gpa);
+ const full = try panes.terminal.screenTextAlloc(pane, gpa);
defer gpa.free(full);
try std.testing.expect(std.mem.startsWith(u8, full, "top\nsecond\nthird\n"));
}
test "terminal eviction and lower-region scrolling in one read preserve a surviving edit" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
defer p.deinit();
@@ -2067,7 +2071,7 @@ test "terminal eviction and lower-region scrolling in one read preserve a surviv
pane.ovl = .{ .row = old_row, .rows = 1, .text = try gpa.dupe(u8, "kept edit") };
pane.body.cur_row = old_row;
pane.body.cur_pinned = true;
- panes.Terminal.pushUndo(p, pane);
+ panes.terminal.pushUndo(p, pane);
const old_total = pages.total_rows;
var buf: [128]u8 = undefined;
const bytes = try std.fmt.bufPrint(&buf, "new\r\n\x1b[4;{d}r\x1b[{d};1Hregion\r\n", .{ pane.rows, pane.rows });
@@ -3338,11 +3342,11 @@ const TerminalTests = struct {
// Tests asserting raw ANSI indices explicitly select helix, whose palette
// is inherited. Native-palette coverage lives in the overlay/eviction and
// trailing-blank tests and checks their concrete RGB values instead.
- const gridOffset = panes.Terminal.gridOffset;
- const scrollGrid = panes.Terminal.scrollGrid;
- const promptInputReady = panes.Terminal.promptInputReady;
- const forwardKey = panes.Terminal.forwardKey;
- const enterTty = panes.Terminal.enterTty;
+ const gridOffset = panes.terminal.gridOffset;
+ const scrollGrid = panes.terminal.scrollGrid;
+ const promptInputReady = panes.terminal.promptInputReady;
+ const forwardKey = panes.terminal.forwardKey;
+ const enterTty = panes.terminal.enterTty;
// Align normal-mode glyphs with the VT grid and compare their styles.
fn modeStyleDiffs(p: *Pardes, pane: *Pane, gpa: std.mem.Allocator, note: []const u8) !usize {
@@ -4794,7 +4798,7 @@ test "Zig keywords keep every rendered byte colored across line-number widths" {
}
test "terminal overlays preserve trailing blank styles without coloring inserted rows" {
- if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
defer p.deinit();
diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json
index 659e49d1..567a8b02 100644
--- a/test/perf-baseline-gui-Debug.json
+++ b/test/perf-baseline-gui-Debug.json
@@ -1 +1 @@
-{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1841,"med_us":2197,"p90_us":2308,"max_us":2328},{"op":"render","fixture":"medium","min_us":72,"med_us":81,"p90_us":84,"max_us":86},{"op":"key-down","fixture":"medium","min_us":74,"med_us":74,"p90_us":80,"max_us":81},{"op":"key-right","fixture":"medium","min_us":74,"med_us":80,"p90_us":85,"max_us":100},{"op":"page-down","fixture":"medium","min_us":74,"med_us":81,"p90_us":84,"max_us":176},{"op":"wheel","fixture":"medium","min_us":71,"med_us":75,"p90_us":79,"max_us":93},{"op":"edit-char","fixture":"medium","min_us":1086,"med_us":1515,"p90_us":1692,"max_us":1822},{"op":"body-2m","fixture":"medium","min_us":9044,"med_us":16111,"p90_us":19782,"max_us":21405}]}
+{"harness":"75a55b9f838d3f0","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":2022,"med_us":2284,"p90_us":2363,"max_us":2431},{"op":"render","fixture":"medium","min_us":80,"med_us":83,"p90_us":90,"max_us":94},{"op":"key-down","fixture":"medium","min_us":79,"med_us":82,"p90_us":87,"max_us":97},{"op":"key-right","fixture":"medium","min_us":82,"med_us":83,"p90_us":91,"max_us":95},{"op":"page-down","fixture":"medium","min_us":79,"med_us":83,"p90_us":84,"max_us":86},{"op":"wheel","fixture":"medium","min_us":79,"med_us":82,"p90_us":85,"max_us":87},{"op":"edit-char","fixture":"medium","min_us":1190,"med_us":1621,"p90_us":1843,"max_us":2008},{"op":"body-2m","fixture":"medium","min_us":9178,"med_us":16872,"p90_us":20886,"max_us":22596}]}
diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json
index bed59c8c..8ba0cdec 100644
--- a/test/perf-baseline-tty-Debug.json
+++ b/test/perf-baseline-tty-Debug.json
@@ -1 +1 @@
-{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1800,"med_us":2113,"p90_us":2193,"max_us":2326},{"op":"render","fixture":"medium","min_us":71,"med_us":76,"p90_us":85,"max_us":97},{"op":"key-down","fixture":"medium","min_us":73,"med_us":76,"p90_us":82,"max_us":84},{"op":"key-right","fixture":"medium","min_us":72,"med_us":76,"p90_us":81,"max_us":95},{"op":"page-down","fixture":"medium","min_us":74,"med_us":78,"p90_us":83,"max_us":83},{"op":"wheel","fixture":"medium","min_us":72,"med_us":75,"p90_us":82,"max_us":83},{"op":"edit-char","fixture":"medium","min_us":1027,"med_us":1418,"p90_us":1699,"max_us":1834},{"op":"body-2m","fixture":"medium","min_us":8357,"med_us":17722,"p90_us":20080,"max_us":21699}]}
+{"harness":"75a55b9f838d3f0","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1902,"med_us":2386,"p90_us":2484,"max_us":2686},{"op":"render","fixture":"medium","min_us":83,"med_us":87,"p90_us":93,"max_us":118},{"op":"key-down","fixture":"medium","min_us":88,"med_us":94,"p90_us":100,"max_us":156},{"op":"key-right","fixture":"medium","min_us":83,"med_us":86,"p90_us":88,"max_us":92},{"op":"page-down","fixture":"medium","min_us":78,"med_us":84,"p90_us":89,"max_us":101},{"op":"wheel","fixture":"medium","min_us":77,"med_us":84,"p90_us":89,"max_us":132},{"op":"edit-char","fixture":"medium","min_us":1177,"med_us":1519,"p90_us":1721,"max_us":1909},{"op":"body-2m","fixture":"medium","min_us":8533,"med_us":15772,"p90_us":21361,"max_us":23276}]}
diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json
index d13442a5..ab5ed3b6 100644
--- a/test/perf-baseline-tty-ReleaseFast.json
+++ b/test/perf-baseline-tty-ReleaseFast.json
@@ -1 +1 @@
-{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1819,"med_us":2002,"p90_us":2068,"max_us":2122},{"op":"render","fixture":"medium","min_us":71,"med_us":76,"p90_us":88,"max_us":89},{"op":"key-down","fixture":"medium","min_us":73,"med_us":75,"p90_us":76,"max_us":82},{"op":"key-right","fixture":"medium","min_us":73,"med_us":77,"p90_us":86,"max_us":126},{"op":"page-down","fixture":"medium","min_us":71,"med_us":75,"p90_us":81,"max_us":83},{"op":"wheel","fixture":"medium","min_us":71,"med_us":74,"p90_us":76,"max_us":97},{"op":"edit-char","fixture":"medium","min_us":1036,"med_us":1493,"p90_us":1805,"max_us":1967},{"op":"body-2m","fixture":"medium","min_us":8482,"med_us":17227,"p90_us":20296,"max_us":23040}]}
+{"harness":"75a55b9f838d3f0","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":2061,"med_us":2326,"p90_us":2462,"max_us":2503},{"op":"render","fixture":"medium","min_us":76,"med_us":80,"p90_us":86,"max_us":98},{"op":"key-down","fixture":"medium","min_us":78,"med_us":83,"p90_us":91,"max_us":95},{"op":"key-right","fixture":"medium","min_us":77,"med_us":81,"p90_us":86,"max_us":90},{"op":"page-down","fixture":"medium","min_us":77,"med_us":81,"p90_us":89,"max_us":90},{"op":"wheel","fixture":"medium","min_us":75,"med_us":82,"p90_us":87,"max_us":94},{"op":"edit-char","fixture":"medium","min_us":1277,"med_us":1713,"p90_us":1948,"max_us":2063},{"op":"body-2m","fixture":"medium","min_us":9261,"med_us":17043,"p90_us":23512,"max_us":23722}]}
diff --git a/test/perf.zig b/test/perf.zig
index c639178f..fca4a3b0 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -275,12 +275,12 @@ fn measureCreation(io: std.Io, reps: usize, json: bool) !void {
const allocator = counter.allocator();
const begin = nowNs();
const pane = if (terminal)
- try pardes.panes.Terminal.create(allocator, screen_cols, screen_rows)
+ try pardes.panes.terminal.create(allocator, screen_cols, screen_rows)
else
- try pardes.panes.Terminal.createDoc(allocator, screen_cols, screen_rows);
+ try pardes.panes.terminal.createDoc(allocator, screen_cols, screen_rows);
const constructed = nowNs();
const retained = counter.live;
- pardes.panes.Terminal.deinitEmulator(pane, allocator);
+ pardes.panes.terminal.deinitEmulator(pane, allocator);
allocator.destroy(pane);
const destroyed = nowNs();
if (counter.live != 0) return error.LeakedPaneMemory;
@@ -707,7 +707,7 @@ fn checkTerminalTail(core: *pardes.Pardes, surface: *pardes.Surface, batch: usiz
const pane = core.panes[0] orelse return error.MissingTerminal;
const rect = core.rects[0];
const x = rect.x + pardes.config.GUTTER;
- const y = (if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + pardes.panes.Terminal.gridCursor(pane).y;
+ const y = (if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + pardes.panes.terminal.gridCursor(pane).y;
var expected_buffer: [32]u8 = undefined;
const expected = try std.fmt.bufPrint(&expected_buffer, "TURN {d:0>10} READY", .{batch});
for (expected, 0..) |byte, col| {
@@ -773,7 +773,7 @@ fn measureUndoOutput(core: *pardes.Pardes, counter: *Allocations, arena: *std.he
pane.body.cur_row = first + 1;
pane.body.cur_col = 1;
pane.body.vsel = .{ .active = true, .row = first, .col = 0 };
- pardes.panes.Terminal.pushUndo(core, pane);
+ pardes.panes.terminal.pushUndo(core, pane);
core.gpa.free(pane.ovl.?.text);
pane.ovl = null;
if (pane.body.ed_undo_len != index + 1) return error.TerminalUndoSnapshotFailed;
@@ -782,7 +782,7 @@ fn measureUndoOutput(core: *pardes.Pardes, counter: *Allocations, arena: *std.he
for (pane.body.ed_undo[0..pane.body.ed_undo_len]) |snapshot| if (snapshot.ovl) |overlay| core.gpa.free(overlay.text);
pane.body.ed_undo_len = 0;
}
- pardes.panes.Terminal.enterTty(core, 0);
+ pardes.panes.terminal.enterTty(core, 0);
pump(core);
const tracked_before = pages.countTrackedPins();
const start = try pages.trackPin(pages.pin(.{ .screen = .{ .y = @intCast(first) } }).?);
@@ -895,7 +895,7 @@ fn measureTerminalSession(io: std.Io, mib: usize, reps: usize, json: bool) !void
}
const stream_wall_ns = nowNs() - stream_begin;
const live_after_output = counter.live;
- const first_row = @as(i32, @intCast(state.vt.screens.active.pages.scrollbar().offset)) + @as(i32, pardes.panes.Terminal.gridCursor(pane).y);
+ const first_row = @as(i32, @intCast(state.vt.screens.active.pages.scrollbar().offset)) + @as(i32, pardes.panes.terminal.gridCursor(pane).y);
_ = arena.reset(.retain_capacity);
const modal_begin = nowNs();
core.update(.{ .key = .{ .cp = core.opts.tty_toggle, .ctrl = true } });
@@ -943,7 +943,7 @@ fn measureTerminalSession(io: std.Io, mib: usize, reps: usize, json: bool) !void
const begin = nowNs();
core.update(.{ .output = .{ .pane = 0, .bytes = update } });
pump(core);
- const cursor_row = pardes.panes.Terminal.gridOffset(pane) + @as(i32, pardes.panes.Terminal.gridCursor(pane).y);
+ const cursor_row = pardes.panes.terminal.gridOffset(pane) + @as(i32, pardes.panes.terminal.gridCursor(pane).y);
pane.ovl.?.row = if (phase == 0) 0 else cursor_row - 1;
pane.body.cur_row = pane.ovl.?.row;
pane.body.cur_col = 0;
@@ -1023,12 +1023,12 @@ const MiniMeasurement = struct {
live_after_deinit: usize = 0,
};
-fn checkMini(actual: pardes.panes.Mini.Result, expected: pardes.panes.Mini.Result) !void {
+fn checkMini(actual: pardes.panes.mini.Result, expected: pardes.panes.mini.Result) !void {
if (!std.mem.eql(u8, actual.content, expected.content) or !std.mem.eql(u8, actual.colors, expected.colors))
return error.MiniOutputMismatch;
}
-fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const u8, source: []const u8, styles: []const u8, expected: pardes.panes.Mini.Result, reps: usize) !MiniMeasurement {
+fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const u8, source: []const u8, styles: []const u8, expected: pardes.panes.mini.Result, reps: usize) !MiniMeasurement {
const samples = try gpa.alloc(u64, reps);
defer gpa.free(samples);
const allocators = pardes.memory.init(fallback);
@@ -1059,7 +1059,7 @@ fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const
defer arena.deinit();
if (core) |p| {
pump(p);
- try pardes.panes.Mini.open(p, 0, path);
+ try pardes.panes.mini.open(p, 0, path);
for (p.panes, 0..) |slot, id| if (slot != null and id != p.active) try p.removePane(id, null);
pump(p);
p.settings.colors = true;
@@ -1095,7 +1095,7 @@ fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const
else
styles;
defer if (probe == .highlight_generate) syntax_allocator.free(highlighted);
- const converted = try pardes.panes.Mini.generate(allocator, source, highlighted);
+ const converted = try pardes.panes.mini.generate(allocator, source, highlighted);
const elapsed = nowNs() - begin;
defer converted.deinit(allocator);
if (index == 0) result.cold_ns = elapsed else samples[index - 1] = elapsed;
@@ -1151,7 +1151,7 @@ fn measureMini(io: std.Io, fallback: std.mem.Allocator, reps: usize, json: bool)
defer gpa.free(styles);
if (styles.len != source.len or std.mem.indexOfScalar(u8, styles, @intFromEnum(pardes.syntax.Syn.comment)) == null)
return error.MiniSyntaxUnavailable;
- const expected = try pardes.panes.Mini.generate(gpa, source, styles);
+ const expected = try pardes.panes.mini.generate(gpa, source, styles);
defer expected.deinit(gpa);
if (expected.content.len == 0 or expected.content.len != expected.colors.len or !std.unicode.utf8ValidateSlice(expected.content))
return error.InvalidMiniOutput;
diff --git a/test/snapshots/theme.golden b/test/snapshots/theme.golden
index 622c704b..f4e4f9ec 100644
--- a/test/snapshots/theme.golden
+++ b/test/snapshots/theme.golden
@@ -523,7 +523,7 @@
== style acme grid=100x31
|0: 0-50 #000000,#eaffff, 51-59 #000000,#9eeeee, 60-99 #000000,#eaffff,
|1: 0-1 d,#8888cc, 2-2 d,#eaffff, 3-49 #000000,#eaffff, 50-51 #000000,#8888cc, 52-99 #000000,#eaffff,
-|2: 0-1 d,#8888cc, 2-2 d,#eaffff, 3-49 #000000,#eaffff, 50-51 #000000,#b9c3e5, 52-99 #000000,#eaffff,
+|2: 0-1 d,#8888cc, 2-2 d,#eaffff, 3-29 #000000,#eaffff, 30-38 #000099,#eaffff, 39-49 #000000,#eaffff, 50-51 #000000,#b9c3e5, 52-79 #000000,#eaffff, 80-88 #000099,#eaffff, 89-99 #000000,#eaffff,
|3: 0-2 d,#eaffff, 3-99 #000000,#eaffff,
|4: 0-1 d,#ffffea, 2-99 #000000,#ffffea,
|5: 0-1 d,#ffffea, 2-99 #000000,#ffffea,
@@ -538,7 +538,7 @@
|14: 0-99 d,#ffffea,
|15: 0-99 d,#ffffea,
|16: 0-99 d,#ffffea,
-|17: 0-1 d,#b9c3e5, 2-2 d,#eaffff, 3-49 #000000,#eaffff, 50-99 #000000,#ffffea,
+|17: 0-1 d,#b9c3e5, 2-2 d,#eaffff, 3-29 #000000,#eaffff, 30-38 #000099,#eaffff, 39-49 #000000,#eaffff, 50-99 #000000,#ffffea,
|18: 0-2 d,#eaffff, 3-49 #000000,#eaffff, 50-99 #000000,#ffffea,
|19: 0-1 d,#ffffea, 2-99 #000000,#ffffea,
|20: 0-1 d,#ffffea, 2-99 #000000,#ffffea,
@@ -589,7 +589,7 @@
== style dark-plus grid=100x31
|0: 0-50 #a7a7a7,#3c3c3c, 51-59 #d4d4d4,#264f78, 60-60 #d4d4d4,#3c3c3c, 61-99 #a7a7a7,#3c3c3c,
|1: 0-1 d,#458276, 2-2 d,#1e1e1e, 3-49 #ffffff,#1e1e1e, 50-51 #ffffff,#458276, 52-52 #ffffff,#3c3c3c, 53-99 #a7a7a7,#3c3c3c,
-|2: 0-1 d,#007fd4, 2-2 d,#1e1e1e, 3-49 #ffffff,#1e1e1e, 50-51 #ffffff,#1c608e, 52-52 #ffffff,#3c3c3c, 53-99 #a7a7a7,#3c3c3c,
+|2: 0-1 d,#007fd4, 2-2 d,#1e1e1e, 3-29 #ffffff,#1e1e1e, 30-38 #cbe5fc,#1e1e1e, 39-49 #ffffff,#1e1e1e, 50-51 #ffffff,#1c608e, 52-52 #ffffff,#3c3c3c, 53-79 #a7a7a7,#3c3c3c, 80-88 #68aee9,#3c3c3c, 89-99 #a7a7a7,#3c3c3c,
|3: 0-2 d,#1e1e1e, 3-49 #ffffff,#1e1e1e, 50-52 #ffffff,#3c3c3c, 53-99 #a7a7a7,#3c3c3c,
|4: 0-0 d,#424242, 1-1 d,#1e1e1e, 2-49 #d4d4d4,#1e1e1e, 50-50 #d4d4d4,#424242, 51-99 #d4d4d4,#1e1e1e,
|5: 0-0 d,#424242, 1-1 d,#1e1e1e, 2-49 #d4d4d4,#1e1e1e, 50-50 #d4d4d4,#424242, 51-99 #d4d4d4,#1e1e1e,
@@ -604,7 +604,7 @@
|14: 0-0 d,#424242, 1-49 d,#1e1e1e, 50-50 d,#424242, 51-99 d,#1e1e1e,
|15: 0-0 d,#424242, 1-49 d,#1e1e1e, 50-50 d,#424242, 51-99 d,#1e1e1e,
|16: 0-0 d,#424242, 1-49 d,#1e1e1e, 50-50 d,#424242, 51-99 d,#1e1e1e,
-|17: 0-1 d,#1c608e, 2-2 d,#3c3c3c, 3-49 #a7a7a7,#3c3c3c, 50-50 #a7a7a7,#424242, 51-99 #a7a7a7,#1e1e1e,
+|17: 0-1 d,#1c608e, 2-2 d,#3c3c3c, 3-29 #a7a7a7,#3c3c3c, 30-38 #68aee9,#3c3c3c, 39-49 #a7a7a7,#3c3c3c, 50-50 #a7a7a7,#424242, 51-99 #a7a7a7,#1e1e1e,
|18: 0-2 d,#3c3c3c, 3-49 #a7a7a7,#3c3c3c, 50-50 #a7a7a7,#424242, 51-99 #a7a7a7,#1e1e1e,
|19: 0-0 d,#424242, 1-1 d,#1e1e1e, 2-49 #d4d4d4,#1e1e1e, 50-50 #d4d4d4,#424242, 51-99 #d4d4d4,#1e1e1e,
|20: 0-0 d,#424242, 1-1 d,#1e1e1e, 2-49 #d4d4d4,#1e1e1e, 50-50 #d4d4d4,#424242, 51-99 #d4d4d4,#1e1e1e,
@@ -622,7 +622,7 @@
== style solarized-dark grid=100x31
|0: 0-50 #01090b,#657b83, 51-59 #002b36,#586e75, 60-60 #002b36,#657b83, 61-99 #01090b,#657b83,
|1: 0-1 d,#758a41, 2-2 d,#93a1a1, 3-49 #073642,#93a1a1, 50-51 #073642,#758a41, 52-52 #073642,#657b83, 53-99 #01090b,#657b83,
-|2: 0-1 d,#2483c5, 2-2 d,#93a1a1, 3-49 #073642,#93a1a1, 50-51 #073642,#185986, 52-52 #073642,#657b83, 53-99 #01090b,#657b83,
+|2: 0-1 d,#2483c5, 2-2 d,#93a1a1, 3-29 #073642,#93a1a1, 30-38 #2d3502,#93a1a1, 39-49 #073642,#93a1a1, 50-51 #073642,#185986, 52-52 #073642,#657b83, 53-79 #01090b,#657b83, 80-88 #121600,#657b83, 89-99 #01090b,#657b83,
|3: 0-2 d,#93a1a1, 3-49 #073642,#93a1a1, 50-52 #073642,#657b83, 53-99 #01090b,#657b83,
|4: 0-0 d,#839496, 1-1 d,#002b36, 2-49 #839496,#002b36, 50-50 #839496,#839496, 51-99 #839496,#002b36,
|5: 0-0 d,#839496, 1-1 d,#002b36, 2-49 #839496,#002b36, 50-50 #839496,#839496, 51-99 #839496,#002b36,
@@ -637,7 +637,7 @@
|14: 0-0 d,#839496, 1-49 d,#002b36, 50-50 d,#839496, 51-99 d,#002b36,
|15: 0-0 d,#839496, 1-49 d,#002b36, 50-50 d,#839496, 51-99 d,#002b36,
|16: 0-0 d,#839496, 1-49 d,#002b36, 50-50 d,#839496, 51-99 d,#002b36,
-|17: 0-1 d,#185986, 2-2 d,#657b83, 3-49 #01090b,#657b83, 50-50 #01090b,#839496, 51-99 #01090b,#002b36,
+|17: 0-1 d,#185986, 2-2 d,#657b83, 3-29 #01090b,#657b83, 30-38 #121600,#657b83, 39-49 #01090b,#657b83, 50-50 #01090b,#839496, 51-99 #01090b,#002b36,
|18: 0-2 d,#657b83, 3-49 #01090b,#657b83, 50-50 #01090b,#839496, 51-99 #01090b,#002b36,
|19: 0-0 d,#839496, 1-1 d,#002b36, 2-49 #839496,#002b36, 50-50 #839496,#839496, 51-99 #839496,#002b36,
|20: 0-0 d,#839496, 1-1 d,#002b36, 2-49 #839496,#002b36, 50-50 #839496,#839496, 51-99 #839496,#002b36,
diff --git a/test/snapshots/themesel.golden b/test/snapshots/themesel.golden
index 05a8af39..d9c4f4ba 100644
--- a/test/snapshots/themesel.golden
+++ b/test/snapshots/themesel.golden
@@ -533,7 +533,7 @@
== style step-acme grid=100x31
|0: 0-99 #000000,#eaffff,
|1: 0-1 d,#8888cc, 2-2 d,#eaffff, 3-99 #000000,#eaffff,
-|2: 0-1 d,#b9c3e5, 2-2 d,#eaffff, 3-99 #000000,#eaffff,
+|2: 0-1 d,#b9c3e5, 2-2 d,#eaffff, 3-32 #000000,#eaffff, 33-38 #000099,#eaffff, 39-99 #000000,#eaffff,
|3: 0-4 d,#ffffea, 5-6 #999980,#ffffea, 7-99 #000000,#ffffea,
|4: 0-4 d,#ffffea, 5-6 #999980,#ffffea, 7-99 #000000,#ffffea,
|5: 0-4 d,#ffffea, 5-6 #999980,#ffffea, 7-99 #000000,#ffffea,
@@ -547,7 +547,7 @@
|13: 0-0 d,#99994c, 1-3 d,#ffffea, 4-6 #999980,#ffffea, 7-99 #000000,#ffffea,
|14: 0-0 d,#99994c, 1-3 d,#ffffea, 4-6 #999980,#ffffea, 7-99 #000000,#ffffea,
|15: 0-0 d,#99994c, 1-3 d,#ffffea, 4-6 #999980,#ffffea, 7-99 #000000,#ffffea,
-|16: 0-1 d,#8888cc, 2-2 d,#eaffff, 3-99 #000000,#eaffff,
+|16: 0-1 d,#8888cc, 2-2 d,#eaffff, 3-32 #000000,#eaffff, 33-40 #000099,#eaffff, 41-99 #000000,#eaffff,
|17: 0-4 d,#ffffea, 5-6 #999980,#ffffea, 7-92 #000000,#ffffea, 93-99 #000000,#eaffff,
|18: 0-0 d,#99994c, 1-4 d,#ffffea, 5-6 #999980,#ffffea, 7-99 #000000,#ffffea,
|19: 0-0 d,#99994c, 1-4 d,#ffffea, 5-6 #999980,#ffffea, 7-99 #000000,#ffffea,
@@ -627,7 +627,7 @@
== style step-dark-plus grid=100x31
|0: 0-99 #a7a7a7,#3c3c3c,
|1: 0-1 d,#458276, 2-2 d,#1e1e1e, 3-99 #ffffff,#1e1e1e,
-|2: 0-1 d,#1c608e, 2-2 d,#3c3c3c, 3-99 #a7a7a7,#3c3c3c,
+|2: 0-1 d,#1c608e, 2-2 d,#3c3c3c, 3-32 #a7a7a7,#3c3c3c, 33-38 #68aee9,#3c3c3c, 39-99 #a7a7a7,#3c3c3c,
|3: 0-0 d,#424242, 1-4 d,#1e1e1e, 5-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|4: 0-0 d,#424242, 1-4 d,#1e1e1e, 5-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|5: 0-0 d,#424242, 1-4 d,#1e1e1e, 5-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
@@ -641,7 +641,7 @@
|13: 0-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|14: 0-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|15: 0-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
-|16: 0-1 d,#007fd4, 2-2 d,#1e1e1e, 3-99 #ffffff,#1e1e1e,
+|16: 0-1 d,#007fd4, 2-2 d,#1e1e1e, 3-32 #ffffff,#1e1e1e, 33-40 #cbe5fc,#1e1e1e, 41-99 #ffffff,#1e1e1e,
|17: 0-0 d,#424242, 1-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-92 #d4d4d4,#1e1e1e, 93-93 #d4d4d4,#3c3c3c, 94-99 #a7a7a7,#3c3c3c,
|18: 0-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|19: 0-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
@@ -674,7 +674,7 @@
== style step-solarized-dark grid=100x31
|0: 0-99 #01090b,#657b83,
|1: 0-1 d,#758a41, 2-2 d,#93a1a1, 3-99 #073642,#93a1a1,
-|2: 0-1 d,#185986, 2-2 d,#657b83, 3-99 #01090b,#657b83,
+|2: 0-1 d,#185986, 2-2 d,#657b83, 3-32 #01090b,#657b83, 33-38 #121600,#657b83, 39-99 #01090b,#657b83,
|3: 0-0 d,#839496, 1-4 d,#002b36, 5-6 #586e75,#002b36, 7-99 #839496,#002b36,
|4: 0-0 d,#839496, 1-4 d,#002b36, 5-6 #586e75,#002b36, 7-99 #839496,#002b36,
|5: 0-0 d,#839496, 1-4 d,#002b36, 5-6 #586e75,#002b36, 7-99 #839496,#002b36,
@@ -688,7 +688,7 @@
|13: 0-0 d,#eee8d5, 1-3 d,#002b36, 4-6 #586e75,#002b36, 7-99 #839496,#002b36,
|14: 0-0 d,#eee8d5, 1-3 d,#002b36, 4-6 #586e75,#002b36, 7-99 #839496,#002b36,
|15: 0-0 d,#eee8d5, 1-3 d,#002b36, 4-6 #586e75,#002b36, 7-99 #839496,#002b36,
-|16: 0-1 d,#2483c5, 2-2 d,#93a1a1, 3-99 #073642,#93a1a1,
+|16: 0-1 d,#2483c5, 2-2 d,#93a1a1, 3-32 #073642,#93a1a1, 33-40 #2d3502,#93a1a1, 41-99 #073642,#93a1a1,
|17: 0-0 d,#839496, 1-3 d,#002b36, 4-6 #586e75,#002b36, 7-92 #839496,#002b36, 93-93 #839496,#657b83, 94-99 #01090b,#657b83,
|18: 0-0 d,#eee8d5, 1-3 d,#002b36, 4-6 #586e75,#002b36, 7-99 #839496,#002b36,
|19: 0-0 d,#eee8d5, 1-3 d,#002b36, 4-6 #586e75,#002b36, 7-99 #839496,#002b36,
@@ -707,7 +707,7 @@
== style step-back grid=100x31
|0: 0-99 #a7a7a7,#3c3c3c,
|1: 0-1 d,#458276, 2-2 d,#1e1e1e, 3-99 #ffffff,#1e1e1e,
-|2: 0-1 d,#1c608e, 2-2 d,#3c3c3c, 3-99 #a7a7a7,#3c3c3c,
+|2: 0-1 d,#1c608e, 2-2 d,#3c3c3c, 3-32 #a7a7a7,#3c3c3c, 33-38 #68aee9,#3c3c3c, 39-99 #a7a7a7,#3c3c3c,
|3: 0-0 d,#424242, 1-4 d,#1e1e1e, 5-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|4: 0-0 d,#424242, 1-4 d,#1e1e1e, 5-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|5: 0-0 d,#424242, 1-4 d,#1e1e1e, 5-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
@@ -721,7 +721,7 @@
|13: 0-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|14: 0-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|15: 0-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
-|16: 0-1 d,#007fd4, 2-2 d,#1e1e1e, 3-99 #ffffff,#1e1e1e,
+|16: 0-1 d,#007fd4, 2-2 d,#1e1e1e, 3-32 #ffffff,#1e1e1e, 33-40 #cbe5fc,#1e1e1e, 41-99 #ffffff,#1e1e1e,
|17: 0-0 d,#424242, 1-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-92 #d4d4d4,#1e1e1e, 93-93 #d4d4d4,#3c3c3c, 94-99 #a7a7a7,#3c3c3c,
|18: 0-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|19: 0-3 d,#1e1e1e, 4-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
@@ -768,7 +768,7 @@
|29: 27 line 27
|30: 28 line 28
== style picked grid=100x31
-|2: 0-1 d,#007fd4, 2-2 d,#1e1e1e, 3-99 #ffffff,#1e1e1e,
+|2: 0-1 d,#007fd4, 2-2 d,#1e1e1e, 3-32 #ffffff,#1e1e1e, 33-38 #cbe5fc,#1e1e1e, 39-99 #ffffff,#1e1e1e,
|3: 0-0 d,#424242, 1-1 d,#1e1e1e, 2-4 d,#1e1e1e,b 5-6 #c6c6c6,#1e1e1e,b 7-99 #d4d4d4,#1e1e1e,
|7: 0-0 d,#424242, 1-4 d,#1e1e1e, 5-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,
|8: 0-0 d,#424242, 1-4 d,#1e1e1e, 5-6 #858585,#1e1e1e, 7-99 #d4d4d4,#1e1e1e,