diff options
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. @@ -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 @@ -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. @@ -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. @@ -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; @@ -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; @@ -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, |
